Добавил запрос на создание препода от кафедры

This commit is contained in:
dipatrik10
2026-06-02 00:01:14 +03:00
parent 4b05d4287c
commit 4adce0b89c
23 changed files with 1370 additions and 51 deletions

View File

@@ -2,17 +2,22 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateTeacherCreationRequest;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@@ -26,21 +31,29 @@ import java.util.Objects;
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController {
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService;
}
@@ -127,6 +140,7 @@ public class DepartmentWorkspaceController {
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
@@ -136,6 +150,90 @@ public class DepartmentWorkspaceController {
.toList();
}
@PostMapping("/teachers/{teacherId}/assignments")
@Transactional
public ResponseEntity<?> addTeacherToDepartment(@PathVariable Long teacherId,
@RequestBody(required = false) Map<String, Object> request) {
Long requestedDepartmentId = asLong(request == null ? null : request.get("departmentId"));
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = userRepository.findById(teacherId).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
}
LocalDate today = LocalDate.now();
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
}
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(today);
assignment.setPrimaryAssignment(false);
assignment.setComment(trimToNull(request == null ? null : request.get("comment")));
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
return ResponseEntity.ok(toAssignmentMap(assignment));
}
@GetMapping("/teacher-requests")
public List<TeacherCreationRequestResponse> getTeacherRequests(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
if (effectiveDepartmentId == null) {
return teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
return teacherCreationRequestRepository.findByDepartment_IdOrderByCreatedAtDesc(effectiveDepartmentId).stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
@PostMapping("/teacher-requests")
public ResponseEntity<?> createTeacherRequest(@RequestBody CreateTeacherCreationRequest request) {
if (request == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные заявки обязательны"));
}
Long departmentId = effectiveDepartmentId(request == null ? null : request.getDepartmentId());
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
String username = trimToNull(request.getUsername());
String fullName = trimToNull(request.getFullName());
String jobTitle = trimToNull(request.getJobTitle());
if (username == null || fullName == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин и ФИО преподавателя обязательны"));
}
if (username.length() > 50 || fullName.length() > 255 || (jobTitle != null && jobTitle.length() > 255)) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей заявки"));
}
if (userRepository.findByUsername(username).isPresent()
|| teacherCreationRequestRepository.existsByUsernameAndStatus(username, TeacherCreationRequestStatus.PENDING)) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин уже занят или ожидает одобрения"));
}
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
teacherRequest.setDepartment(department);
teacherRequest.setUsername(username);
teacherRequest.setFullName(fullName);
teacherRequest.setJobTitle(jobTitle == null ? "Не указано" : jobTitle);
teacherRequest.setComment(trimToNull(request.getComment()));
teacherRequest.setRequestedBy(AuthContext.currentUserId());
return ResponseEntity.ok(toTeacherCreationRequestResponse(teacherCreationRequestRepository.save(teacherRequest)));
}
@GetMapping("/schedule")
public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long departmentId,
@@ -172,10 +270,64 @@ public class DepartmentWorkspaceController {
return response;
}
private TeacherCreationRequestResponse toTeacherCreationRequestResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private Map<String, Object> toAssignmentMap(TeacherDepartmentAssignment assignment) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", assignment.getId());
response.put("teacherId", assignment.getTeacher().getId());
response.put("teacherName", assignment.getTeacher().getFullName());
response.put("departmentId", assignment.getDepartment().getId());
response.put("departmentName", assignment.getDepartment().getDepartmentName());
response.put("validFrom", assignment.getValidFrom());
response.put("primaryAssignment", assignment.getPrimaryAssignment());
return response;
}
private Long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && !text.isBlank()) {
try {
return Long.parseLong(text);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String trimToNull(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) {
return user.departmentId();
return user.departmentId() == null ? NO_DEPARTMENT_ID : user.departmentId();
}
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
}

View File

@@ -0,0 +1,190 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/teacher-requests")
@RequireRoles({Role.ADMIN})
public class TeacherCreationRequestController {
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
BCryptPasswordEncoder passwordEncoder) {
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.passwordEncoder = passwordEncoder;
}
@GetMapping
public List<TeacherCreationRequestResponse> getRequests(@RequestParam(required = false) TeacherCreationRequestStatus status) {
return (status == null
? teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc()
: teacherCreationRequestRepository.findByStatusOrderByCreatedAtDesc(status))
.stream()
.map(this::toResponse)
.toList();
}
@PostMapping("/{id}/approve")
@Transactional
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
if (review == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные одобрения обязательны"));
}
String username = firstNotBlank(review.getUsername(), request.getUsername());
String fullName = firstNotBlank(review.getFullName(), request.getFullName());
String jobTitle = firstNotBlank(review.getJobTitle(), request.getJobTitle(), "Не указано");
if (username.length() > 50 || fullName.length() > 255 || jobTitle.length() > 255) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей преподавателя"));
}
if (review.getPassword() == null || review.getPassword().length() < 8) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль преподавателя должен быть не короче 8 символов"));
}
if (userRepository.findByUsername(username).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь с таким логином уже существует"));
}
Long departmentId = review.getDepartmentId() == null ? request.getDepartment().getId() : review.getDepartmentId();
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = new User();
teacher.setUsername(username);
teacher.setPassword(passwordEncoder.encode(review.getPassword()));
teacher.setRole(Role.TEACHER);
teacher.setFullName(fullName);
teacher.setJobTitle(jobTitle);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Создан по заявке кафедры #" + request.getId());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
request.setUsername(username);
request.setFullName(fullName);
request.setJobTitle(jobTitle);
request.setDepartment(department);
request.setStatus(TeacherCreationRequestStatus.APPROVED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewComment(trimToNull(review.getReviewComment()));
request.setCreatedTeacherId(teacher.getId());
teacherCreationRequestRepository.save(request);
return ResponseEntity.ok(Map.of(
"request", toResponse(request),
"teacher", toUserResponse(teacher, department)
));
}
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id, @RequestBody(required = false) ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
request.setStatus(TeacherCreationRequestStatus.REJECTED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
}
private TeacherCreationRequestResponse toResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private UserResponse toUserResponse(User user, Department department) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
department.getDepartmentName()
);
response.setDepartmentId(department.getId());
response.setStatus(user.getStatus());
return response;
}
private String firstNotBlank(String... values) {
for (String value : values) {
String normalized = trimToNull(value);
if (normalized != null) {
return normalized;
}
}
return "";
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}

View File

@@ -24,8 +24,10 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/users")
@@ -75,12 +77,12 @@ public class UserController {
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
Role.TEACHER,
departmentId,
LifecycleEntity.STATUS_ARCHIVED
);
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
@@ -96,6 +98,11 @@ public class UserController {
@PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request == null) {
String errorMessage = "Данные пользователя обязательны";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
if (request.getUsername() == null || request.getUsername().isBlank()) {
@@ -103,8 +110,8 @@ public class UserController {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
String errorMessage = "Пароль минимум 4 символа";
if (request.getPassword() == null || request.getPassword().length() < 8) {
String errorMessage = "Пароль минимум 8 символов";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
@@ -127,6 +134,12 @@ public class UserController {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Department requestedDepartment = departmentRepository.findById(request.getDepartmentId()).orElse(null);
if (requestedDepartment == null || requestedDepartment.isArchivedRecord()) {
String errorMessage = "Активная кафедра не найдена";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Role role;
try {
@@ -145,18 +158,15 @@ public class UserController {
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
if (role == Role.TEACHER) {
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
if (department != null) {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(department);
assignment.setDepartment(requestedDepartment);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
}
}
logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(toUserResponse(user));
@@ -193,6 +203,13 @@ public class UserController {
if (!userRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
var currentUser = AuthContext.getCurrentUser();
if (currentUser != null
&& currentUser.role() == Role.DEPARTMENT
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
}
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
.map(this::toAssignmentDto)
.toList());
@@ -247,12 +264,12 @@ public class UserController {
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
return ResponseEntity.ok(teacherDepartmentAssignmentRepository
.findDepartmentTeachersAtDate(departmentId, effectiveDate)
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(User::isActiveRecord)
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
.map(this::toUserResponse)
.toList());
}
@@ -289,4 +306,28 @@ public class UserController {
assignment.getComment()
);
}
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
Map<Long, User> teachersById = new LinkedHashMap<>();
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
if (assignments != null) {
assignments.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
}
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
if (directTeachers != null) {
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
}
return List.copyOf(teachersById.values());
}
private boolean canAccessDepartment(Long departmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser == null
|| currentUser.role() != Role.DEPARTMENT
|| (departmentId != null && departmentId.equals(currentUser.departmentId()));
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.dto;
public class CreateTeacherCreationRequest {
private String username;
private String fullName;
private String jobTitle;
private String comment;
private Long departmentId;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
}

View File

@@ -0,0 +1,59 @@
package com.magistr.app.dto;
public class ReviewTeacherCreationRequest {
private String username;
private String fullName;
private String jobTitle;
private Long departmentId;
private String password;
private String reviewComment;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getReviewComment() {
return reviewComment;
}
public void setReviewComment(String reviewComment) {
this.reviewComment = reviewComment;
}
}

View File

@@ -0,0 +1,21 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
public record TeacherCreationRequestResponse(
Long id,
Long departmentId,
String departmentName,
String username,
String fullName,
String jobTitle,
String comment,
String status,
Long requestedBy,
LocalDateTime createdAt,
Long reviewedBy,
LocalDateTime reviewedAt,
String reviewComment,
Long createdTeacherId
) {
}

View File

@@ -0,0 +1,168 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "teacher_creation_requests")
public class TeacherCreationRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
@Column(nullable = false, length = 50)
private String username;
@Column(name = "full_name", nullable = false, length = 255)
private String fullName;
@Column(name = "job_title", nullable = false, length = 255)
private String jobTitle;
@Column(columnDefinition = "TEXT")
private String comment;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TeacherCreationRequestStatus status = TeacherCreationRequestStatus.PENDING;
@Column(name = "requested_by")
private Long requestedBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "created_teacher_id")
private Long createdTeacherId;
@PreUpdate
public void touch() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public Department getDepartment() {
return department;
}
public void setDepartment(Department department) {
this.department = department;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public TeacherCreationRequestStatus getStatus() {
return status;
}
public void setStatus(TeacherCreationRequestStatus status) {
this.status = status == null ? TeacherCreationRequestStatus.PENDING : status;
}
public Long getRequestedBy() {
return requestedBy;
}
public void setRequestedBy(Long requestedBy) {
this.requestedBy = requestedBy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public Long getReviewedBy() {
return reviewedBy;
}
public void setReviewedBy(Long reviewedBy) {
this.reviewedBy = reviewedBy;
}
public LocalDateTime getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(LocalDateTime reviewedAt) {
this.reviewedAt = reviewedAt;
}
public String getReviewComment() {
return reviewComment;
}
public void setReviewComment(String reviewComment) {
this.reviewComment = reviewComment;
}
public Long getCreatedTeacherId() {
return createdTeacherId;
}
public void setCreatedTeacherId(Long createdTeacherId) {
this.createdTeacherId = createdTeacherId;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum TeacherCreationRequestStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@@ -0,0 +1,18 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherCreationRequest;
import com.magistr.app.model.TeacherCreationRequestStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
List<TeacherCreationRequest> findByStatusOrderByCreatedAtDesc(TeacherCreationRequestStatus status);
List<TeacherCreationRequest> findAllByOrderByCreatedAtDesc();
boolean existsByUsernameAndStatus(String username, TeacherCreationRequestStatus status);
}

View File

@@ -38,7 +38,6 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
join fetch a.teacher
join fetch a.department
where a.department.id = :departmentId
and a.primaryAssignment = true
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
order by a.teacher.fullName
@@ -47,4 +46,18 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
@Query("""
select count(a) > 0
from TeacherDepartmentAssignment a
where a.teacher.id = :teacherId
and a.department.id = :departmentId
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
""")
boolean existsActiveAssignment(
@Param("teacherId") Long teacherId,
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
}

View File

@@ -133,6 +133,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
ON teacher_department_assignments(teacher_id)
WHERE is_primary = TRUE AND valid_to IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
ON teacher_department_assignments(teacher_id, department_id)
WHERE valid_to IS NULL;
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u
@@ -143,6 +147,34 @@ WHERE u.role = 'TEACHER'
WHERE existing.teacher_id = u.id
);
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
username VARCHAR(50) NOT NULL,
full_name VARCHAR(255) NOT NULL,
job_title VARCHAR(255) NOT NULL,
comment TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
requested_by BIGINT REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_by BIGINT REFERENCES users(id),
reviewed_at TIMESTAMP,
review_comment TEXT,
created_teacher_id BIGINT REFERENCES users(id),
CONSTRAINT chk_teacher_creation_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED'))
);
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_department
ON teacher_creation_requests(department_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_status
ON teacher_creation_requests(status, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_creation_requests_pending_username
ON teacher_creation_requests(username)
WHERE status = 'PENDING';
-- ==========================================
-- Образовательные формы
-- ==========================================
@@ -938,6 +970,7 @@ COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расп
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';

View File

@@ -11,18 +11,23 @@ import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DepartmentWorkspaceControllerTest {
@@ -43,7 +48,9 @@ class DepartmentWorkspaceControllerTest {
subjectRepository,
mock(SubjectCommentRepository.class),
mock(UserRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentAssignmentRepository.class),
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
@@ -67,7 +74,9 @@ class DepartmentWorkspaceControllerTest {
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
mock(DepartmentRepository.class),
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
@@ -77,6 +86,39 @@ class DepartmentWorkspaceControllerTest {
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
}
@Test
void departmentCanAddExistingTeacherAsSecondaryAssignment() {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
User teacher = user(20L, "teacher", "Иванов Иван Иванович", 2L);
Department department = new Department(1L, "Кафедра ИБ", 1L);
when(userRepository.findById(20L)).thenReturn(Optional.of(teacher));
when(departmentRepository.findById(1L)).thenReturn(Optional.of(department));
when(assignmentRepository.existsActiveAssignment(20L, 1L, LocalDate.now())).thenReturn(false);
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
departmentRepository,
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));
var response = controller.addTeacherToDepartment(20L, Map.of("comment", "Совместительство"));
assertThat(response.getStatusCode().value()).isEqualTo(200);
ArgumentCaptor<TeacherDepartmentAssignment> captor = ArgumentCaptor.forClass(TeacherDepartmentAssignment.class);
verify(assignmentRepository).save(captor.capture());
TeacherDepartmentAssignment saved = captor.getValue();
assertThat(saved.getTeacher()).isEqualTo(teacher);
assertThat(saved.getDepartment()).isEqualTo(department);
assertThat(saved.getPrimaryAssignment()).isFalse();
assertThat(saved.getCreatedBy()).isEqualTo(3L);
}
private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
Department department = new Department(departmentId, "Кафедра", departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();

View File

@@ -23,13 +23,16 @@ class UserControllerTest {
void teachersUseUnifiedUserResponseMapping() {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
UserController controller = new UserController(
userRepository,
null,
departmentRepository,
mock(TeacherDepartmentAssignmentRepository.class)
assignmentRepository
);
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
.thenReturn(List.of());
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(teacher));
when(departmentRepository.findById(2L))

View File

@@ -147,6 +147,8 @@ Refresh-токен ротируется при каждом успешном о
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
### `POST /api/users`
Создание пользователя.
@@ -165,7 +167,7 @@ Refresh-токен ротируется при каждом успешном о
**Валидация:**
- `username` — обязателен и уникален
- `password` — минимум 4 символа
- `password` — минимум 8 символов
- `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
- `fullName` — обязателен
- `departmentId` — обязателен
@@ -196,7 +198,40 @@ Refresh-токен ротируется при каждом успешном о
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
Список преподавателей кафедры на конкретную дату по таблице истории.
Список преподавателей кафедры на конкретную дату по таблице истории и legacy-привязке `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
---
## Заявки на создание преподавателей
Администратор просматривает и обрабатывает заявки кафедр на создание новых преподавателей.
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
**Тело одобрения:**
```json
{
"departmentId": 2,
"username": "teacher.new",
"password": "secure-pass",
"fullName": "Новый Преподаватель",
"jobTitle": "Доцент",
"reviewComment": "Данные проверены"
}
```
Пароль задаёт только администратор при одобрении заявки. Сама заявка пароль не хранит.
**Тело отклонения:**
```json
{
"reviewComment": "Нужно уточнить ФИО"
}
```
---
@@ -539,10 +574,33 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
| `POST` | `/api/department/teachers/{teacherId}/assignments` | Добавить существующего преподавателя на кафедру |
| `GET` | `/api/department/teacher-requests` | Заявки кафедры на создание преподавателей |
| `POST` | `/api/department/teacher-requests` | Создать заявку на нового преподавателя |
| `GET` | `/api/department/schedule` | Расписание кафедры |
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
`POST /api/department/teachers/{teacherId}/assignments` создаёт дополнительную открытую связь преподавателя с кафедрой (`is_primary=false`). Для роли `DEPARTMENT` кафедра берётся из текущего пользователя, администратор может передать `departmentId`.
```json
{
"departmentId": 2,
"comment": "Совместительство"
}
```
`POST /api/department/teacher-requests` принимает логин, ФИО, должность и комментарий. Повторная pending-заявка с тем же логином запрещена.
```json
{
"username": "teacher.new",
"fullName": "Новый Преподаватель",
"jobTitle": "Доцент",
"comment": "Нужен для дисциплин кафедры"
}
```
---
## Кафедры и специальности

View File

@@ -8,7 +8,7 @@
|------|------|------------|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
@@ -27,7 +27,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение;
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя.
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя;
- добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой;
- заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки.
---
@@ -93,14 +95,19 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
### Перевод преподавателей между кафедрами
### Кафедральные связи преподавателей
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
Основная кафедра преподавателя хранится в `users.department_id` для совместимости, а актуальные и исторические связи фиксируются в `teacher_department_assignments`. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся основной, дополнительные связи создаются как неосновные.
Правила:
- у преподавателя должна быть одна открытая основная кафедра;
- преподаватель может иметь несколько открытых неосновных кафедр;
- одна открытая пара `teacher_id` + `department_id` запрещает дубли одной и той же связи;
- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
- кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры;
- кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль;
- администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`;
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
---

View File

@@ -148,6 +148,21 @@ erDiagram
TEXT comment
}
teacher_creation_requests {
BIGSERIAL id PK
BIGINT department_id FK
VARCHAR username
VARCHAR full_name
VARCHAR job_title
TEXT comment
VARCHAR status
BIGINT requested_by FK
BIGINT reviewed_by FK
TIMESTAMP reviewed_at
TEXT review_comment
BIGINT created_teacher_id FK
}
subject_comments {
BIGSERIAL id PK
BIGINT subject_id FK
@@ -306,6 +321,8 @@ erDiagram
users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_id"
departments ||--o{ teacher_creation_requests : "department_id"
users ||--o{ teacher_creation_requests : "requested_by/reviewed_by/created_teacher_id"
users ||--o{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id"
@@ -528,7 +545,27 @@ erDiagram
| `created_at` | TIMESTAMP | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод |
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя.
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. Индекс `uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной кафедрой, но позволяет преподавателю иметь несколько открытых неосновных кафедр.
#### `teacher_creation_requests` — Заявки кафедр на создание преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID заявки |
| `department_id` | BIGINT FK → departments | Кафедра, которая запрашивает преподавателя |
| `username` | VARCHAR(50) | Предлагаемый логин |
| `full_name` | VARCHAR(255) | ФИО преподавателя |
| `job_title` | VARCHAR(255) | Должность |
| `comment` | TEXT | Комментарий кафедры |
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
| `reviewed_at` | TIMESTAMP | Дата рассмотрения |
| `review_comment` | TEXT | Комментарий администратора |
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
| `created_at` | TIMESTAMP | Дата создания заявки |
| `updated_at` | TIMESTAMP | Дата последнего изменения |
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
#### `subject_comments` — Комментарии к дисциплинам
| Колонка | Тип | Описание |
@@ -713,13 +750,13 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
> Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов

View File

@@ -129,14 +129,14 @@ frontend/
| Tab | Описание | API |
|-----|----------|-----|
| `users` | CRUD пользователей | `/api/users` |
| `users` | CRUD пользователей и очередь заявок кафедр на создание преподавателей | `/api/users`, `/api/teacher-requests` |
| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `edu-forms` | Формы обучения | `/api/education-forms` |
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
| `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
| `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/calendar/activity-types`, `/api/education-forms` |
@@ -147,7 +147,9 @@ frontend/
### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `users` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
@@ -208,6 +210,8 @@ export const api = {
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
После мутаций `api.js` инвалидирует кэш не только по точному URL, но и по связанным префиксам. Для заявок и кафедральных привязок очищаются `/api/users`, `/api/users/teachers`, `/api/teacher-requests`, `/api/department/teacher-requests` и `/api/department/teachers`, чтобы таблицы заявок и списки преподавателей обновлялись без ручного сброса страницы.
---
## Аутентификация (Frontend)

View File

@@ -396,6 +396,37 @@
align-items: stretch;
}
.department-workspace-grid-wide {
grid-template-columns: repeat(4, minmax(220px, 1fr));
}
.teacher-request-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
min-width: 180px;
}
#teacher-requests-tbody input,
#teacher-requests-tbody select {
min-width: 150px;
width: 100%;
padding: 0.5rem 0.65rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font: inherit;
font-size: 0.85rem;
}
#teacher-requests-tbody input:focus,
#teacher-requests-tbody select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.department-subject-import-card,
.department-teachers-card {
display: flex;
@@ -696,6 +727,10 @@
}
@media (max-width: 900px) {
.department-workspace-grid-wide {
grid-template-columns: 1fr;
}
.academic-year-semesters {
grid-template-columns: 1fr;
}

View File

@@ -109,18 +109,26 @@ function invalidateCache(url) {
'/api/specialties',
'/api/education-forms',
'/api/users',
'/api/teacher-requests',
'/api/department/teacher-requests',
'/api/department/teachers',
'/api/equipments',
'/api/classrooms'
];
for (const prefix of cacheKeysToClear) {
if (url.includes(prefix)) {
const affectedPrefixes = new Set(cacheKeysToClear.filter(prefix => url.includes(prefix)));
if (url.includes('/api/teacher-requests')) {
affectedPrefixes.add('/api/users');
}
if (url.includes('/api/department/teachers')) {
affectedPrefixes.add('/api/users/teachers');
}
for (const prefix of affectedPrefixes) {
for (const cacheKey of cache.keys()) {
if (cacheKey.includes(prefix)) {
cache.delete(cacheKey);
}
}
}
}
}
export function clearCache() {

View File

@@ -14,6 +14,12 @@ export async function initDepartmentWorkspace() {
const importForm = document.getElementById('department-subject-import-form');
const importRowsContainer = document.getElementById('subject-import-rows');
const importAddButton = document.getElementById('subject-import-add');
const assignTeacherForm = document.getElementById('department-teacher-assign-form');
const assignTeacherSelect = document.getElementById('department-teacher-select');
const assignTeacherComment = document.getElementById('department-teacher-assign-comment');
const teacherRequestForm = document.getElementById('department-teacher-request-form');
const teacherRequestsTbody = document.getElementById('department-teacher-requests-tbody');
const teacherRequestCount = document.getElementById('department-teacher-request-count');
const subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card');
@@ -25,6 +31,8 @@ export async function initDepartmentWorkspace() {
const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null;
let lastWorkload = null;
let allTeachers = [];
let teacherRequests = null;
let teachersErrorMessage = '';
let workloadErrorMessage = '';
@@ -34,6 +42,8 @@ export async function initDepartmentWorkspace() {
startInput?.addEventListener('change', handleStartDateChange);
endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects);
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
importRowsContainer?.addEventListener('click', (event) => {
const removeBtn = event.target.closest('.subject-import-remove');
@@ -61,6 +71,7 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true;
departmentSelect.value = userDepartmentId || '';
}
await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]);
await loadWorkspace();
} catch (error) {
@@ -74,7 +85,9 @@ export async function initDepartmentWorkspace() {
teachersErrorMessage = '';
workloadErrorMessage = '';
renderTeachers();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
teacherRequests = null;
renderTeacherRequests();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
}
async function loadSubjects() {
@@ -165,6 +178,68 @@ export async function initDepartmentWorkspace() {
}
}
async function assignTeacherToDepartment(event) {
event.preventDefault();
const alertId = 'department-teacher-assign-alert';
hideAlert(alertId);
const teacherId = assignTeacherSelect?.value;
const departmentId = selectedDepartmentId();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!teacherId) {
showAlert(alertId, 'Выберите преподавателя', 'error');
return;
}
try {
await api.post(`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`, {
departmentId: Number(departmentId),
comment: assignTeacherComment?.value.trim() || null
});
showAlert(alertId, 'Преподаватель привязан к кафедре', 'success');
if (assignTeacherComment) assignTeacherComment.value = '';
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (error) {
showAlert(alertId, error.message || 'Ошибка привязки преподавателя', 'error');
}
}
async function submitTeacherRequest(event) {
event.preventDefault();
const alertId = 'department-teacher-request-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const username = document.getElementById('teacher-request-username')?.value.trim();
const fullName = document.getElementById('teacher-request-fullname')?.value.trim();
const jobTitle = document.getElementById('teacher-request-jobtitle')?.value.trim();
const comment = document.getElementById('teacher-request-comment')?.value.trim();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!username || !fullName) {
showAlert(alertId, 'Заполните логин и ФИО преподавателя', 'error');
return;
}
try {
await api.post('/api/department/teacher-requests', {
departmentId: Number(departmentId),
username,
fullName,
jobTitle,
comment
});
showAlert(alertId, 'Заявка отправлена администратору', 'success');
teacherRequestForm.reset();
await loadTeacherRequests();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка отправки заявки', 'error');
}
}
async function openComments(subjectId, subjectName) {
commentsCard.hidden = false;
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
@@ -215,14 +290,46 @@ export async function initDepartmentWorkspace() {
lastTeachers = null;
teachersErrorMessage = '';
renderTeachers();
renderTeacherSelect();
try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
lastTeachers = Array.isArray(teachers) ? teachers : [];
renderTeachers();
renderTeacherSelect();
} catch (error) {
lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers();
renderTeacherSelect();
}
}
async function loadAllTeachers() {
try {
const teachers = await api.get('/api/users/teachers');
allTeachers = Array.isArray(teachers) ? teachers : [];
renderTeacherSelect();
} catch (error) {
allTeachers = [];
if (assignTeacherSelect) {
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
}
}
}
async function loadTeacherRequests() {
teacherRequests = null;
renderTeacherRequests();
try {
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
teacherRequests = Array.isArray(requests) ? requests : [];
renderTeacherRequests();
} catch (error) {
teacherRequests = [];
if (teacherRequestsTbody) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
}
}
@@ -319,6 +426,49 @@ export async function initDepartmentWorkspace() {
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
function renderTeacherSelect() {
if (!assignTeacherSelect) return;
if (lastTeachers === null) {
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
return;
}
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(teacher => `
<option value="${escapeHtml(teacher.id)}">
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
</option>
`).join('');
if (!availableTeachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
}
}
function renderTeacherRequests() {
if (!teacherRequestsTbody) return;
if (teacherRequests === null) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
return;
}
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
if (!teacherRequests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
<tr>
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
<td>${escapeHtml(request.username || '-')}</td>
<td>${escapeHtml(request.fullName || '-')}</td>
<td>${escapeHtml(request.jobTitle || '-')}</td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
</tr>
`).join('');
}
function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
}
@@ -366,6 +516,30 @@ function subjectCountLabel(count) {
return `${count} дисциплин`;
}
function requestCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} заявок`;
if (count % 10 === 1) return `${count} заявка`;
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
return `${count} заявок`;
}
function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
function renderTeacherMetric(item, workloadErrorMessage) {
return `
<article class="metric-card">

View File

@@ -21,6 +21,9 @@ const ROLE_BADGE = {
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const teacherRequestsTbody = document.getElementById('teacher-requests-tbody');
const teacherRequestsRefresh = document.getElementById('teacher-requests-refresh');
let departments = [];
async function loadUsers() {
try {
@@ -33,6 +36,53 @@ export async function initUsers() {
}
}
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadTeacherRequests() {
if (!teacherRequestsTbody) return;
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
renderTeacherRequests(Array.isArray(requests) ? requests : []);
} catch (error) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
}
function renderTeacherRequests(requests) {
if (!requests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
</div>
</td>
</tr>
`).join('');
}
function renderUsers(users) {
if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
@@ -71,6 +121,10 @@ export async function initUsers() {
showAlert('create-alert', 'Заполните все поля', 'error');
return;
}
if (password.length < 8) {
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
const data = await api.post('/api/users', {
@@ -89,6 +143,54 @@ export async function initUsers() {
}
});
teacherRequestsRefresh?.addEventListener('click', loadTeacherRequests);
teacherRequestsTbody?.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await Promise.all([loadTeacherRequests(), loadUsers()]);
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
return;
}
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadTeacherRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
});
usersTbody.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return;
@@ -114,5 +216,6 @@ export async function initUsers() {
}
});
await loadUsers();
await loadDepartments();
await Promise.all([loadUsers(), loadTeacherRequests()]);
}

View File

@@ -24,7 +24,7 @@
<div class="form-alert" id="department-workspace-alert" role="alert"></div>
</div>
<div class="department-workspace-grid">
<div class="department-workspace-grid department-workspace-grid-wide">
<div class="card create-card">
<div class="card-header-row">
<h2>Загрузка дисциплин</h2>
@@ -42,6 +42,52 @@
</form>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2>Добавить преподавателя</h2>
</div>
<form id="department-teacher-assign-form" class="stack-form">
<div class="form-group">
<label for="department-teacher-select">Преподаватель</label>
<select id="department-teacher-select" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="department-teacher-assign-comment">Комментарий</label>
<input type="text" id="department-teacher-assign-comment" placeholder="Например, ведет лабораторные работы">
</div>
<button type="submit" class="btn btn-md btn-secondary">Привязать к кафедре</button>
<div class="form-alert" id="department-teacher-assign-alert" role="alert"></div>
</form>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2>Заявка на преподавателя</h2>
</div>
<form id="department-teacher-request-form" class="stack-form">
<div class="form-group">
<label for="teacher-request-username">Логин</label>
<input type="text" id="teacher-request-username" placeholder="ivanov_teacher" maxlength="50" required>
</div>
<div class="form-group">
<label for="teacher-request-fullname">ФИО</label>
<input type="text" id="teacher-request-fullname" placeholder="Иванов Иван Иванович" maxlength="255" required>
</div>
<div class="form-group">
<label for="teacher-request-jobtitle">Должность</label>
<input type="text" id="teacher-request-jobtitle" placeholder="Доцент" maxlength="255">
</div>
<div class="form-group">
<label for="teacher-request-comment">Комментарий</label>
<input type="text" id="teacher-request-comment" placeholder="Что важно проверить администратору">
</div>
<button type="submit" class="btn btn-md btn-primary">Отправить заявку</button>
<div class="form-alert" id="department-teacher-request-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Преподаватели и нагрузка</h2>
@@ -50,6 +96,30 @@
</div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Заявки кафедры</h2>
<span class="muted" id="department-teacher-request-count">0 заявок</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Статус</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Комментарий</th>
<th>Рассмотрение</th>
</tr>
</thead>
<tbody id="department-teacher-requests-tbody">
<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Дисциплины кафедры</h2>

View File

@@ -9,7 +9,7 @@
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="text" id="new-password" placeholder="password" required>
<input type="text" id="new-password" placeholder="Минимум 8 символов" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>
@@ -40,6 +40,32 @@
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Заявки на преподавателей</h2>
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
</div>
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>Все пользователи</h2>
<div class="table-wrap">