From 4adce0b89ca43990c64ad868cfaa959264a2715b Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Tue, 2 Jun 2026 00:01:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B0=20=D0=BE=D1=82=20=D0=BA=D0=B0=D1=84?= =?UTF-8?q?=D0=B5=D0=B4=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DepartmentWorkspaceController.java | 154 +++++++++++++- .../TeacherCreationRequestController.java | 190 ++++++++++++++++++ .../app/controller/UserController.java | 87 +++++--- .../app/dto/CreateTeacherCreationRequest.java | 50 +++++ .../app/dto/ReviewTeacherCreationRequest.java | 59 ++++++ .../dto/TeacherCreationRequestResponse.java | 21 ++ .../app/model/TeacherCreationRequest.java | 168 ++++++++++++++++ .../model/TeacherCreationRequestStatus.java | 7 + .../TeacherCreationRequestRepository.java | 18 ++ ...TeacherDepartmentAssignmentRepository.java | 15 +- .../main/resources/db/migration/V1__init.sql | 33 +++ .../DepartmentWorkspaceControllerTest.java | 42 ++++ .../app/controller/UserControllerTest.java | 5 +- docs/API.md | 62 +++++- docs/BUSINESS_LOGIC.md | 15 +- docs/DATABASE.md | 43 +++- docs/FRONTEND.md | 8 +- frontend/admin/css/components.css | 35 ++++ frontend/admin/js/api.js | 20 +- .../admin/js/views/department-workspace.js | 176 +++++++++++++++- frontend/admin/js/views/users.js | 105 +++++++++- .../admin/views/department-workspace.html | 72 ++++++- frontend/admin/views/users.html | 36 +++- 23 files changed, 1370 insertions(+), 51 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/CreateTeacherCreationRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/ReviewTeacherCreationRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java create mode 100644 backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java create mode 100644 backend/src/main/java/com/magistr/app/model/TeacherCreationRequestStatus.java create mode 100644 backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java index d8ac277..0d7d9d5 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -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 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 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 toAssignmentMap(TeacherDepartmentAssignment assignment) { + Map 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; } diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java new file mode 100644 index 0000000..c6b0289 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index dc088d7..9c2ae1a 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -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 users = userRepository.findByRoleAndDepartmentIdAndStatusNot( - Role.TEACHER, - departmentId, - LifecycleEntity.STATUS_ARCHIVED - ); + List 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,17 +158,14 @@ 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.setValidFrom(LocalDate.now()); - assignment.setPrimaryAssignment(true); - assignment.setComment("Начальная кафедра при создании пользователя"); - assignment.setCreatedBy(AuthContext.currentUserId()); - teacherDepartmentAssignmentRepository.save(assignment); - } + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(user); + assignment.setDepartment(requestedDepartment); + assignment.setValidFrom(LocalDate.now()); + assignment.setPrimaryAssignment(true); + assignment.setComment("Начальная кафедра при создании пользователя"); + assignment.setCreatedBy(AuthContext.currentUserId()); + teacherDepartmentAssignmentRepository.save(assignment); } logger.info("Пользователь успешно создан с ID: {}", user.getId()); @@ -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 teachersForDepartment(Long departmentId, LocalDate date) { + Map teachersById = new LinkedHashMap<>(); + List 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 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())); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/CreateTeacherCreationRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateTeacherCreationRequest.java new file mode 100644 index 0000000..2c646f2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateTeacherCreationRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/ReviewTeacherCreationRequest.java b/backend/src/main/java/com/magistr/app/dto/ReviewTeacherCreationRequest.java new file mode 100644 index 0000000..acc35e6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ReviewTeacherCreationRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java b/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java new file mode 100644 index 0000000..f6d921a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java new file mode 100644 index 0000000..3f4cda5 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherCreationRequestStatus.java b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequestStatus.java new file mode 100644 index 0000000..764b145 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequestStatus.java @@ -0,0 +1,7 @@ +package com.magistr.app.model; + +public enum TeacherCreationRequestStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java new file mode 100644 index 0000000..44dfaec --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java @@ -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 { + + List findByDepartment_IdOrderByCreatedAtDesc(Long departmentId); + + List findByStatusOrderByCreatedAtDesc(TeacherCreationRequestStatus status); + + List findAllByOrderByCreatedAtDesc(); + + boolean existsByUsernameAndStatus(String username, TeacherCreationRequestStatus status); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java index 8caaf02..be94f19 100644 --- a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java @@ -38,7 +38,6 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository= :date) order by a.teacher.fullName @@ -47,4 +46,18 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository 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 + ); } diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 5332257..a395425 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -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 'Лимит академических часов лекций'; diff --git a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java index 9f281e9..2d41912 100644 --- a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java @@ -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 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(); diff --git a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java index b751e4e..5c39d3d 100644 --- a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java @@ -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)) diff --git a/docs/API.md b/docs/API.md index faa2506..462560c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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": "Нужен для дисциплин кафедры" +} +``` + --- ## Кафедры и специальности diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 88722a7..8088e01 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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`; - расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index bc3f180..556bf46 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d8f54d5..8f665d4 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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) diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index c760698..1189ebe 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -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; } diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index 0b679db..2fbadac 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -109,15 +109,23 @@ 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)) { - for (const cacheKey of cache.keys()) { - if (cacheKey.includes(prefix)) { - cache.delete(cacheKey); - } + 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); } } } diff --git a/frontend/admin/js/views/department-workspace.js b/frontend/admin/js/views/department-workspace.js index c4f2cbe..1614365 100644 --- a/frontend/admin/js/views/department-workspace.js +++ b/frontend/admin/js/views/department-workspace.js @@ -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 = ``; + } + } + } + + 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 = `${escapeHtml(error.message || 'Ошибка загрузки заявок')}`; + } + if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок'; } } @@ -319,6 +426,49 @@ export async function initDepartmentWorkspace() { container.innerHTML = `
${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}
`; } + function renderTeacherSelect() { + if (!assignTeacherSelect) return; + if (lastTeachers === null) { + assignTeacherSelect.innerHTML = ''; + return; + } + const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id))); + const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id))); + assignTeacherSelect.innerHTML = '' + + availableTeachers.map(teacher => ` + + `).join(''); + if (!availableTeachers.length) { + assignTeacherSelect.innerHTML = ''; + } + } + + function renderTeacherRequests() { + if (!teacherRequestsTbody) return; + if (teacherRequests === null) { + teacherRequestsTbody.innerHTML = 'Загрузка...'; + if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...'; + return; + } + if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length); + if (!teacherRequests.length) { + teacherRequestsTbody.innerHTML = 'Заявок пока нет'; + return; + } + teacherRequestsTbody.innerHTML = teacherRequests.map(request => ` + + ${escapeHtml(requestStatusLabel(request.status))} + ${escapeHtml(request.username || '-')} + ${escapeHtml(request.fullName || '-')} + ${escapeHtml(request.jobTitle || '-')} + ${escapeHtml(request.comment || '-')} + ${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')} + + `).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 `
diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js index 4130bcf..92be2a7 100644 --- a/frontend/admin/js/views/users.js +++ b/frontend/admin/js/views/users.js @@ -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 = 'Загрузка...'; + hideAlert('teacher-requests-alert'); + try { + const requests = await api.get('/api/teacher-requests?status=PENDING'); + renderTeacherRequests(Array.isArray(requests) ? requests : []); + } catch (error) { + teacherRequestsTbody.innerHTML = `${escapeHtml(error.message || 'Ошибка загрузки заявок')}`; + } + } + + function renderTeacherRequests(requests) { + if (!requests.length) { + teacherRequestsTbody.innerHTML = 'Нет заявок на рассмотрении'; + return; + } + teacherRequestsTbody.innerHTML = requests.map(request => ` + + + + + + + + + ${escapeHtml(request.comment || '-')} + +
+ + +
+ + + `).join(''); + } + function renderUsers(users) { if (!users || !users.length) { usersTbody.innerHTML = 'Нет пользователей'; @@ -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()]); } diff --git a/frontend/admin/views/department-workspace.html b/frontend/admin/views/department-workspace.html index c09ff6c..d85a4b8 100644 --- a/frontend/admin/views/department-workspace.html +++ b/frontend/admin/views/department-workspace.html @@ -24,7 +24,7 @@ -
+

Загрузка дисциплин

@@ -42,6 +42,52 @@
+
+
+

Добавить преподавателя

+
+
+
+ + +
+
+ + +
+ + +
+
+ +
+
+

Заявка на преподавателя

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+

Преподаватели и нагрузка

@@ -50,6 +96,30 @@
+
+
+

Заявки кафедры

+ 0 заявок +
+
+ + + + + + + + + + + + + + +
СтатусЛогинФИОДолжностьКомментарийРассмотрение
Загрузка...
+
+
+

Дисциплины кафедры

diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index b084dd3..51ded41 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -1,5 +1,5 @@ -
+

Новый пользователь

@@ -9,7 +9,7 @@
- +
@@ -38,9 +38,35 @@
-
- -
+
+ +
+
+

Заявки на преподавателей

+ +
+ +
+ + + + + + + + + + + + + + + +
КафедраЛогинФИОДолжностьПарольКомментарийДействия
Загрузка...
+
+
+ +

Все пользователи