Добавил запрос на создание препода от кафедры
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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<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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum TeacherCreationRequestStatus {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 'Лимит академических часов лекций';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
62
docs/API.md
62
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": "Нужен для дисциплин кафедры"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Кафедры и специальности
|
||||
|
||||
@@ -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`;
|
||||
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
|
||||
|
||||
---
|
||||
|
||||
@@ -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-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user