13 Commits

Author SHA1 Message Date
db0218f4ca Merge pull request 'Redesign' (#13) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 14s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 13s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m13s
Reviewed-on: #13
2026-06-25 16:43:54 +00:00
9e6e0038c6 Отображение вкладок в структуре вуза 2026-06-04 23:46:26 +03:00
6a03931190 исправлена 500 ошибка, пустое пространство в модалке 2026-06-04 10:50:12 +03:00
dipatrik10
3d50b6b006 Исправил косяки в модалках решения конфликтов правил 2026-06-03 21:22:24 +03:00
dipatrik10
53298d9d07 Добавил возможность решения конкфликтов при создании правил 2026-06-03 13:55:31 +03:00
776728cf83 Структура вуза, визуал 2026-06-03 12:16:37 +03:00
dipatrik10
08b8607f05 Добавил вкладку заявок 2026-06-02 16:07:30 +03:00
6d9f6b7a42 Изменен фильтр в "группы обучения", изменена механика создания подгрупп 2026-06-02 11:21:02 +03:00
4e1985c28e исправил чекбоксы оборудования в аудиторном фонде 2026-06-02 00:29:23 +03:00
dipatrik10
a966648a7e Сделал обработку на ВЕ конфликтных правил 2026-06-02 00:24:57 +03:00
dipatrik10
f3273f6972 Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-06-02 00:02:06 +03:00
dipatrik10
4adce0b89c Добавил запрос на создание препода от кафедры 2026-06-02 00:01:14 +03:00
da7c90fa6a Merge pull request 'Redesign' (#12) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 50s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 14s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m49s
Reviewed-on: #12
2026-06-01 09:04:35 +00:00
41 changed files with 3501 additions and 230 deletions

View File

@@ -166,6 +166,7 @@ public class AcademicCalendarController {
} }
try { try {
replaceCalendarSubjects(calendar, rows == null ? List.of() : rows); replaceCalendarSubjects(calendar, rows == null ? List.of() : rows);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream() return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto) .map(this::toCalendarSubjectDto)
.toList()); .toList());
@@ -284,6 +285,7 @@ public class AcademicCalendarController {
} }
calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId()); calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId());
calendarSubjectRepository.flush();
List<AcademicCalendarSubject> entities = rows.stream() List<AcademicCalendarSubject> entities = rows.stream()
.map(row -> { .map(row -> {
AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject(); AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject();

View File

@@ -2,17 +2,22 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateTeacherCreationRequest;
import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto; import com.magistr.app.dto.SubjectCommentDto;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse; import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService; import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
@@ -26,21 +31,29 @@ import java.util.Objects;
@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) @RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController { public class DepartmentWorkspaceController {
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository; private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository; private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService; private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository, public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository, SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository, UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository, TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) { ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository; this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository; this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService; this.scheduleQueryService = scheduleQueryService;
} }
@@ -127,6 +140,7 @@ public class DepartmentWorkspaceController {
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now()) teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
.stream() .stream()
.map(TeacherDepartmentAssignment::getTeacher) .map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord) .filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher)); .forEach(teacher -> teachersById.put(teacher.getId(), teacher));
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED) userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
@@ -136,6 +150,90 @@ public class DepartmentWorkspaceController {
.toList(); .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") @GetMapping("/schedule")
public ResponseEntity<?> getSchedule( public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long departmentId, @RequestParam(required = false) Long departmentId,
@@ -172,10 +270,64 @@ public class DepartmentWorkspaceController {
return response; 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) { private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser(); var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) { 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; return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
} }

View File

@@ -6,11 +6,13 @@ import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.*; import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
@RestController @RestController
@@ -19,6 +21,12 @@ import java.util.*;
public class ScheduleRuleAdminController { public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT"; private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
ScheduleParity.BOTH,
ScheduleParity.ODD,
ScheduleParity.EVEN
);
private final ScheduleRuleRepository scheduleRuleRepository; private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
@@ -81,6 +89,10 @@ public class ScheduleRuleAdminController {
try { try {
ScheduleRule rule = new ScheduleRule(); ScheduleRule rule = new ScheduleRule();
applyRule(rule, request); applyRule(rule, request);
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
if (conflict != null) {
return conflictResponse(conflict);
}
ScheduleRule saved = scheduleRuleRepository.save(rule); ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved)); return ResponseEntity.ok(toDto(saved));
@@ -103,6 +115,12 @@ public class ScheduleRuleAdminController {
} }
try { try {
ScheduleRule candidate = new ScheduleRule();
applyRule(candidate, request);
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
if (conflict != null) {
return conflictResponse(conflict);
}
applyRule(rule, request); applyRule(rule, request);
ScheduleRule saved = scheduleRuleRepository.save(rule); ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
@@ -181,12 +199,250 @@ public class ScheduleRuleAdminController {
rule.getSlots().addAll(slots); rule.getSlots().addAll(slots);
} }
private ResponseEntity<?> conflictResponse(ScheduleRuleConflict conflict) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"message", "Невозможно сохранить правило: слот занят",
"conflictRule", toDto(conflict.rule()),
"conflictFields", conflict.fields().stream().toList(),
"conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList()
));
}
private String conflictFieldLabel(String field) {
return switch (field) {
case "teacher" -> "Преподаватель";
case "classroom" -> "Аудитория";
case "group" -> "Группа";
default -> field;
};
}
private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
.filter(Objects::nonNull)
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
.orElse(null);
}
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
Collection<ScheduleRuleSlot> newSlots,
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
Collection<ScheduleRuleSlot> existingSlots) {
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
LinkedHashSet<String> fields = new LinkedHashSet<>();
for (ScheduleRuleSlot newSlot : newSlots) {
for (ScheduleRuleSlot existingSlot : existingSlots) {
if (sameTimeSlot(newSlot, existingSlot)
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
fields.addAll(conflictFields(newSlot, existingSlot));
}
}
}
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
}
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
LinkedHashSet<String> fields = new LinkedHashSet<>();
if (Objects.equals(teacherId(first), teacherId(second))) {
fields.add("teacher");
}
if (Objects.equals(classroomId(first), classroomId(second))) {
fields.add("classroom");
}
if (sameAudience(first, second)) {
fields.add("group");
}
return fields;
}
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
&& parityOverlaps(first.getParity(), second.getParity())
&& Objects.equals(timeSlotId(first), timeSlotId(second));
}
private Long teacherId(ScheduleRuleSlot slot) {
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
}
private Long classroomId(ScheduleRuleSlot slot) {
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
}
private Long timeSlotId(ScheduleRuleSlot slot) {
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
}
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
if (Objects.equals(first, second)) {
return true;
}
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
}
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
Set<Long> firstGroupIds = groupIds(first);
Set<Long> secondGroupIds = groupIds(second);
firstGroupIds.retainAll(secondGroupIds);
for (Long groupId : firstGroupIds) {
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
return true;
}
Set<Long> firstSubgroupIds = subgroupIdsForGroup(first, groupId);
Set<Long> secondSubgroupIds = subgroupIdsForGroup(second, groupId);
firstSubgroupIds.retainAll(secondSubgroupIds);
if (!firstSubgroupIds.isEmpty()) {
return true;
}
}
return false;
}
private Set<Long> groupIds(ScheduleRuleSlot slot) {
Set<Long> ids = new HashSet<>();
if (slot.getScheduleRule() == null) {
return ids;
}
for (StudentGroup group : slot.getScheduleRule().getGroups()) {
ids.add(group.getId());
}
return ids;
}
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
return subgroupIdsForGroup(slot, groupId).isEmpty();
}
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
Set<Long> ids = new HashSet<>();
for (Subgroup subgroup : slot.getSubgroups()) {
if (subgroup.getStudentGroup() != null
&& Objects.equals(subgroup.getStudentGroup().getId(), groupId)) {
ids.add(subgroup.getId());
}
}
return ids;
}
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
if (slots.isEmpty()) {
return activeWeeks;
}
ScheduleRule rule = slots.iterator().next().getScheduleRule();
if (rule == null || rule.getSemester() == null) {
return activeWeeks;
}
int maxWeeks = maxWeeksForRule(rule);
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int totalHours = rule.academicHoursFor(category);
if (totalHours <= 0) {
continue;
}
List<ScheduleRuleSlot> categorySlots = slots.stream()
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.sorted(this::compareSlotsForGeneration)
.toList();
if (categorySlots.isEmpty()) {
continue;
}
Map<String, Integer> consumedHours = new HashMap<>();
int startWeek = Math.max(1, rule.startWeekFor(category));
for (int week = startWeek; week <= maxWeeks; week++) {
for (ScheduleRuleSlot slot : categorySlots) {
if (!slotAppliesToWeek(slot, week)) {
continue;
}
for (String scopeKey : consumptionScopes(slot, category)) {
int consumed = consumedHours.getOrDefault(scopeKey, 0);
if (consumed >= totalHours) {
continue;
}
activeWeeks.get(slot).add(week);
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
}
}
}
}
return activeWeeks;
}
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
return false;
}
Set<Integer> weeks = new HashSet<>(first);
weeks.retainAll(second);
return !weeks.isEmpty();
}
private int maxWeeksForRule(ScheduleRule rule) {
Semester semester = rule.getSemester();
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
return Math.max(1, (int) Math.ceil(days / 7.0));
}
return 18;
}
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
int dayCompare = Integer.compare(
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
);
if (dayCompare != 0) {
return dayCompare;
}
int orderCompare = Integer.compare(
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null ? 0 : first.getTimeSlot().getOrderNumber(),
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null ? 0 : second.getTimeSlot().getOrderNumber()
);
if (orderCompare != 0) {
return orderCompare;
}
return Long.compare(first.getId() == null ? 0L : first.getId(), second.getId() == null ? 0L : second.getId());
}
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
ScheduleParity parity = slot.getParity();
if (parity == null || parity == ScheduleParity.BOTH) {
return true;
}
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
return parity == weekParity;
}
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.map(subgroup -> "subgroup:" + subgroup.getId())
.toList();
}
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
return List.of("rule");
}
return slot.getScheduleRule().getGroups().stream()
.map(group -> "group:" + group.getId())
.toList();
}
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) { private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) { if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7"); throw new IllegalArgumentException("День недели должен быть от 1 до 7");
} }
if (slotDto.parity() == null) { if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
throw new IllegalArgumentException("Чётность недели обязательна"); throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
} }
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId()) TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
@@ -402,4 +658,7 @@ public class ScheduleRuleAdminController {
.thenComparing(Subgroup::getName)) .thenComparing(Subgroup::getName))
.toList(); .toList();
} }
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
}
} }

View File

@@ -66,6 +66,10 @@ public class SubgroupController {
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) { if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
} }
String capacityError = validateGroupCapacity(group, null, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
Subgroup subgroup = new Subgroup(); Subgroup subgroup = new Subgroup();
subgroup.setStudentGroup(group); subgroup.setStudentGroup(group);
@@ -95,6 +99,10 @@ public class SubgroupController {
if (duplicateName) { if (duplicateName) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
} }
String capacityError = validateGroupCapacity(subgroup.getStudentGroup(), id, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
subgroup.setName(newName); subgroup.setName(newName);
subgroup.setStudentCapacity(request.studentCapacity()); subgroup.setStudentCapacity(request.studentCapacity());
@@ -113,6 +121,10 @@ public class SubgroupController {
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) { if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
} }
String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup);
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
try { try {
subgroup.archive("Подгруппа архивирована"); subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup); subgroupRepository.save(subgroup);
@@ -127,8 +139,39 @@ public class SubgroupController {
if (request == null || request.name() == null || request.name().isBlank()) { if (request == null || request.name() == null || request.name().isBlank()) {
return "Название подгруппы обязательно"; return "Название подгруппы обязательно";
} }
if (request.studentCapacity() != null && request.studentCapacity() < 0) { if (request.studentCapacity() == null) {
return "Численность подгруппы не может быть отрицательной"; return "Численность подгруппы обязательна";
}
if (request.studentCapacity() <= 0) {
return "Численность подгруппы должна быть больше 0";
}
return null;
}
private String validateGroupCapacity(StudentGroup group, Long excludedSubgroupId, Integer requestedCapacity) {
Long currentCapacity = excludedSubgroupId == null
? subgroupRepository.sumActiveStudentCapacityByGroupId(group.getId())
: subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(group.getId(), excludedSubgroupId);
long totalCapacity = currentCapacity + requestedCapacity.longValue();
if (totalCapacity > group.getGroupSize()) {
return "Сумма численностей подгрупп не может превышать численность группы (" + group.getGroupSize() + " чел.)";
}
return null;
}
private String validateDeleteKeepsCompleteSplit(Subgroup subgroup) {
StudentGroup group = subgroup.getStudentGroup();
long remainingCount = subgroupRepository.countActiveByGroupIdExcludingId(group.getId(), subgroup.getId());
if (remainingCount == 0) {
return null;
}
Long remainingCapacity = subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(
group.getId(),
subgroup.getId()
);
if (remainingCapacity < group.getGroupSize()) {
return "Нельзя удалить одну подгруппу из деления: измените количество подгрупп в форме настройки или отключите деление";
} }
return null; return null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
and subgroup.status <> 'ARCHIVED' and subgroup.status <> 'ARCHIVED'
""") """)
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name); boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
@Query("""
select coalesce(sum(subgroup.studentCapacity), 0)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
""")
Long sumActiveStudentCapacityByGroupId(@Param("groupId") Long groupId);
@Query("""
select coalesce(sum(subgroup.studentCapacity), 0)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
and subgroup.id <> :excludedId
""")
Long sumActiveStudentCapacityByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
@Query("""
select count(subgroup)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
and subgroup.id <> :excludedId
""")
long countActiveByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
} }

View File

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

View File

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

View File

@@ -133,6 +133,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
ON teacher_department_assignments(teacher_id) ON teacher_department_assignments(teacher_id)
WHERE is_primary = TRUE AND valid_to IS NULL; 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) 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' SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u FROM users u
@@ -143,6 +147,34 @@ WHERE u.role = 'TEACHER'
WHERE existing.teacher_id = u.id 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';
-- ========================================== -- ==========================================
-- Образовательные формы -- Образовательные формы
-- ========================================== -- ==========================================
@@ -955,6 +987,7 @@ COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расп
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания'; COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам'; COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам'; COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар'; COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций'; COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';

View File

@@ -0,0 +1,6 @@
ALTER TABLE subgroups
DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
ON subgroups (group_id, lower(name))
WHERE status <> 'ARCHIVED';

View File

@@ -11,18 +11,23 @@ import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository; 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.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService; import com.magistr.app.service.ScheduleQueryService;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class DepartmentWorkspaceControllerTest { class DepartmentWorkspaceControllerTest {
@@ -43,7 +48,9 @@ class DepartmentWorkspaceControllerTest {
subjectRepository, subjectRepository,
mock(SubjectCommentRepository.class), mock(SubjectCommentRepository.class),
mock(UserRepository.class), mock(UserRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentAssignmentRepository.class), mock(TeacherDepartmentAssignmentRepository.class),
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class) mock(ScheduleQueryService.class)
); );
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L)); AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
@@ -67,7 +74,9 @@ class DepartmentWorkspaceControllerTest {
mock(SubjectRepository.class), mock(SubjectRepository.class),
mock(SubjectCommentRepository.class), mock(SubjectCommentRepository.class),
userRepository, userRepository,
mock(DepartmentRepository.class),
assignmentRepository, assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class) mock(ScheduleQueryService.class)
); );
@@ -77,6 +86,39 @@ class DepartmentWorkspaceControllerTest {
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович"); .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) { private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
Department department = new Department(departmentId, "Кафедра", departmentId); Department department = new Department(departmentId, "Кафедра", departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();

View File

@@ -23,13 +23,16 @@ class UserControllerTest {
void teachersUseUnifiedUserResponseMapping() { void teachersUseUnifiedUserResponseMapping() {
UserRepository userRepository = mock(UserRepository.class); UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class); DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
UserController controller = new UserController( UserController controller = new UserController(
userRepository, userRepository,
null, null,
departmentRepository, departmentRepository,
mock(TeacherDepartmentAssignmentRepository.class) assignmentRepository
); );
User teacher = user(10L, "teacher", Role.TEACHER, 2L); 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)) when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(teacher)); .thenReturn(List.of(teacher));
when(departmentRepository.findById(2L)) when(departmentRepository.findById(2L))

View File

@@ -147,6 +147,8 @@ Refresh-токен ротируется при каждом успешном о
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`. Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
### `POST /api/users` ### `POST /api/users`
Создание пользователя. Создание пользователя.
@@ -165,7 +167,7 @@ Refresh-токен ротируется при каждом успешном о
**Валидация:** **Валидация:**
- `username` — обязателен и уникален - `username` — обязателен и уникален
- `password` — минимум 4 символа - `password` — минимум 8 символов
- `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT` - `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
- `fullName` — обязателен - `fullName` — обязателен
- `departmentId` — обязателен - `departmentId` — обязателен
@@ -196,7 +198,40 @@ Refresh-токен ротируется при каждом успешном о
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01` ### `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": "Нужно уточнить ФИО"
}
```
--- ---
@@ -400,7 +435,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
] ]
``` ```
`semesterNumber` — номер учебного семестра внутри графика: для 4 курсов доступны значения `1..8`. API принимает только существующие неархивные дисциплины из `/api/subjects`, не допускает дубли одной дисциплины в одном семестре и возвращает сохранённые записи с `subjectName`, `subjectCode` и `departmentId`. `semesterNumber` — номер учебного семестра внутри графика: для 4 курсов доступны значения `1..8`. API принимает только существующие неархивные дисциплины из `/api/subjects`, не допускает дубли одной дисциплины в одном семестре и возвращает сохранённые записи с `subjectName`, `subjectCode` и `departmentId`. `PUT /subjects` выполняет атомарную полную замену списка: перед вставкой нового набора старые привязки этого графика удаляются и синхронизируются с БД.
### `POST /api/admin/schedule-rules` ### `POST /api/admin/schedule-rules`
@@ -449,6 +484,25 @@ CRUD доступен по:
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
`parity` принимает только `BOTH`, `ODD` или `EVEN`. При создании и обновлении правила backend проверяет занятость в рамках семестра: конфликтом считается пересечение дня, базового временного слота, чётности и активных недель слотов, если совпадает преподаватель, аудитория или учебная группа. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. При конфликте API возвращает `409 Conflict`:
```json
{
"message": "Невозможно сохранить правило: слот занят",
"conflictRule": {
"id": 12,
"subjectName": "Математический анализ",
"semesterId": 1,
"groupNames": ["ИБ-101"],
"slots": []
},
"conflictFields": ["classroom"],
"conflictReasons": ["Аудитория"]
}
```
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса.
### `GET /api/lesson-types` ### `GET /api/lesson-types`
Справочник типов занятий для конструктора правил расписания. Справочник типов занятий для конструктора правил расписания.
@@ -553,10 +607,33 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины | | `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий | | `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
| `GET` | `/api/department/teachers` | Преподаватели кафедры | | `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/schedule` | Расписание кафедры |
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений. `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": "Нужен для дисциплин кафедры"
}
```
--- ---
## Кафедры и специальности ## Кафедры и специальности
@@ -700,6 +777,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`. Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/subgroups` | Список всех подгрупп | | `GET` | `/api/subgroups` | Список всех подгрупп |

View File

@@ -8,7 +8,7 @@
|------|------|------------| |------|------|------------|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | | **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. | | **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
@@ -27,7 +27,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- загрузка и комментарии дисциплин идут через `/api/department/*`; - загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение; - общий `/api/subjects` доступен кафедре только на чтение;
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя. - привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя;
- добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой;
- заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки.
--- ---
@@ -58,7 +60,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения - **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) - **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика - **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным. - **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
@@ -94,14 +96,19 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы. Методическая проверка активности учитывает период действия и `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`; - при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
- кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры;
- кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль;
- администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`;
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой. - расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
--- ---
@@ -173,13 +180,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа. - **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. - **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы. - **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах. - **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации. - **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю. - **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
- **Конфликты слотов:** при создании и обновлении правил проверяются активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict` с ранее созданным `conflictRule`, чтобы frontend мог предложить перенос этого правила.
### Точечные изменения расписания ### Точечные изменения расписания

View File

@@ -148,6 +148,21 @@ erDiagram
TEXT comment 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 { subject_comments {
BIGSERIAL id PK BIGSERIAL id PK
BIGINT subject_id FK BIGINT subject_id FK
@@ -314,6 +329,8 @@ erDiagram
users ||--o{ auth_refresh_tokens : "user_id" users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id" users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_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{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id" users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ teacher_subjects : "subject_id"
@@ -453,7 +470,7 @@ erDiagram
| `name` | VARCHAR(100) | Название подгруппы | | `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов | | `student_capacity` | INT | Количество студентов |
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий. Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
#### `subjects` — Дисциплины #### `subjects` — Дисциплины
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -538,7 +555,27 @@ erDiagram
| `created_at` | TIMESTAMP | Дата создания записи | | `created_at` | TIMESTAMP | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод | | `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` — Комментарии к дисциплинам #### `subject_comments` — Комментарии к дисциплинам
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -734,13 +771,13 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum. > Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции ### Текущие миграции
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | | `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов ### Накатывание на существующих тенантов

View File

@@ -37,35 +37,31 @@ frontend/
│ │ ├── utils.js # Утилиты │ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод) │ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений │ │ └── views/ # Модули представлений
│ │ ├── dashboard.js # Дашборд
│ │ ├── users.js # Управление пользователями │ │ ├── users.js # Управление пользователями
│ │ ├── teacher-requests.js # Заявки кафедр на преподавателей
│ │ ├── groups.js # Управление группами │ │ ├── groups.js # Управление группами
│ │ ├── classrooms.js # Управление аудиториями │ │ ├── classrooms.js # Управление аудиториями
│ │ ├── subjects.js # Управление дисциплинами │ │ ├── subjects.js # Управление дисциплинами
│ │ ├── equipments.js # Управление оборудованием │ │ ├── university-structure.js # Кафедры, специальности и профили обучения
│ │ ├── edu-forms.js # Формы обучения
│ │ ├── profiles.js # Профили обучения специальностей
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели │ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах │ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
│ │ ├── schedule.js # Конструктор правил расписания │ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── academic-calendar.js # Календарные учебные графики
│ │ ── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр │ │ ── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ │ ├── database.js # Управление тенантами
│ │ └── departments-data.js # Создание кафедры/специальности
│ ├── views/ # HTML-шаблоны представлений │ ├── views/ # HTML-шаблоны представлений
│ │ ├── dashboard.html
│ │ ├── users.html │ │ ├── users.html
│ │ ├── teacher-requests.html
│ │ ├── groups.html │ │ ├── groups.html
│ │ ├── classrooms.html │ │ ├── classrooms.html
│ │ ├── subjects.html │ │ ├── subjects.html
│ │ ├── equipments.html │ │ ├── university-structure.html
│ │ ├── edu-forms.html
│ │ ├── profiles.html
│ │ ├── department-workspace.html │ │ ├── department-workspace.html
│ │ ├── schedule-view.html │ │ ├── schedule-view.html
│ │ ├── schedule.html │ │ ├── schedule.html
│ │ ├── academic-calendar.html │ │ ├── academic-calendar.html
│ │ ── auditorium-workload.html │ │ ── auditorium-workload.html
│ │ ├── database.html
│ │ └── departments-data.html
│ │ │ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA) │ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar │ ├── index.html # Оболочка с собственной sidebar
@@ -129,32 +125,32 @@ frontend/
| Tab | Описание | API | | Tab | Описание | API |
|-----|----------|-----| |-----|----------|-----|
| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
| `users` | CRUD пользователей | `/api/users` | | `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `edu-forms` | Формы обучения | `/api/education-forms` |
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
| `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` | | `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` | | `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | | `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `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` | | `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
| `database` | Тенанты | `/api/database` |
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
### Особенности админских вкладок ### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
@@ -208,6 +204,8 @@ export const api = {
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа. При `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) ## Аутентификация (Frontend)

View File

@@ -385,6 +385,22 @@
align-items: end; align-items: end;
} }
.department-workspace-control-card {
display: grid;
gap: 1rem;
}
.department-workspace-control-card .card-header-row {
margin-bottom: 0;
}
.department-workspace-filter-grid {
display: grid;
grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 0.65fr));
gap: 1rem;
align-items: end;
}
.schedule-view-metrics, .schedule-view-metrics,
.department-workspace-grid { .department-workspace-grid {
display: grid; display: grid;
@@ -396,7 +412,99 @@
align-items: stretch; align-items: stretch;
} }
.department-workspace-grid-wide {
grid-template-columns: repeat(12, minmax(0, 1fr));
align-items: stretch;
}
.department-workspace-actions-grid {
gap: 1rem;
}
.department-subject-import-card {
grid-column: span 7;
grid-row: span 2;
}
.department-teacher-assign-card {
grid-column: span 5;
}
.department-teacher-request-card {
grid-column: span 5;
}
.department-teachers-card {
grid-column: 1 / -1;
}
.department-subject-import-card, .department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card,
.department-teachers-card,
.department-workspace-table-card {
min-width: 0;
}
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card {
display: flex;
flex-direction: column;
}
.department-subject-import-card .stack-form,
.department-teacher-assign-card .stack-form,
.department-teacher-request-card .stack-form {
display: flex;
flex-direction: column;
flex: 1;
}
.department-subject-import-card .subject-import-slots-panel {
flex: 1;
}
.department-subject-import-card .stack-form > .btn,
.department-teacher-assign-card .stack-form > .btn,
.department-teacher-request-card .stack-form > .btn {
align-self: flex-start;
margin-top: auto;
}
.teacher-request-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
min-width: 180px;
}
.teacher-request-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
}
#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-teachers-card { .department-teachers-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -534,6 +642,274 @@
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
} }
.schedule-conflict-modal {
z-index: 1200;
padding: 0.35rem;
overflow-x: hidden;
overflow-y: auto;
}
#schedule-conflict-modal .schedule-conflict-card {
width: min(1760px, calc(100vw - 0.7rem));
max-width: calc(100vw - 0.7rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}
.schedule-conflict-head {
display: grid;
gap: 0.3rem;
margin-right: 2rem;
margin-bottom: 0.65rem;
}
.schedule-conflict-head h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.05rem;
letter-spacing: 0;
text-transform: none;
}
.schedule-conflict-head p {
margin: 0;
color: var(--text-secondary);
line-height: 1.35;
}
.schedule-conflict-kicker {
width: max-content;
padding: 0.2rem 0.55rem;
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.schedule-conflict-reasons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
margin-top: 0.15rem;
}
.schedule-conflict-reasons[hidden] {
display: none;
}
.schedule-conflict-reasons span {
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 700;
}
.schedule-conflict-reasons strong {
padding: 0.2rem 0.5rem;
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
font-size: 0.76rem;
font-weight: 800;
}
.schedule-conflict-rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.schedule-conflict-rule-box {
display: grid;
gap: 0.25rem;
min-width: 0;
padding: 0.65rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-conflict-rule-box-active {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
}
.schedule-conflict-rule-box span,
.schedule-conflict-slot-fixed span {
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.schedule-conflict-rule-box strong,
.schedule-conflict-slot-fixed strong {
min-width: 0;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.schedule-conflict-rule-box small,
.schedule-conflict-slot-fixed small {
color: var(--text-secondary);
line-height: 1.4;
}
.schedule-conflict-form {
display: grid;
gap: 0.55rem;
}
.schedule-conflict-slots {
display: grid;
gap: 0.55rem;
max-height: none;
overflow: visible;
padding-right: 0;
}
.schedule-conflict-slot-row {
display: grid;
grid-template-columns: minmax(160px, 1.05fr) minmax(104px, 0.72fr) minmax(112px, 0.72fr) minmax(132px, 0.85fr) minmax(190px, 1.2fr) minmax(160px, 0.95fr);
gap: 0.5rem;
align-items: end;
min-width: 0;
max-width: 100%;
padding: 0.6rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-input) 88%, transparent);
}
.schedule-conflict-slot-fixed {
display: grid;
gap: 0.25rem;
align-self: stretch;
align-content: center;
min-width: 0;
}
.schedule-conflict-slot-fixed.schedule-conflict-field-hit {
padding-left: 0.55rem;
border-left: 3px solid rgba(245, 158, 11, 0.75);
}
.schedule-conflict-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0;
}
.schedule-conflict-card .form-group,
.schedule-move-slot-card .form-group {
min-width: 0;
}
.schedule-conflict-card .form-group label,
.schedule-move-slot-card .form-group label {
margin-bottom: 0.28rem;
font-size: 0.7rem;
}
.schedule-conflict-card .form-group select,
.schedule-move-slot-card .form-group select {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0.55rem 0.65rem;
font-size: 0.84rem;
}
.schedule-conflict-card .form-alert,
.schedule-move-slot-card .form-alert {
margin-top: 0;
}
.schedule-conflict-field-hit label {
color: var(--warning);
}
.schedule-conflict-field-hit select {
border-color: rgba(245, 158, 11, 0.65);
background: color-mix(in srgb, rgba(245, 158, 11, 0.15) 35%, var(--bg-input));
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.12);
}
.schedule-visual-context-menu {
position: fixed;
z-index: 1300;
display: grid;
min-width: 220px;
padding: 0.35rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
}
.schedule-visual-context-menu[hidden] {
display: none;
}
[data-theme="light"] .schedule-visual-context-menu {
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
}
.schedule-visual-context-menu button {
width: 100%;
padding: 0.65rem 0.75rem;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.88rem;
text-align: left;
cursor: pointer;
}
.schedule-visual-context-menu button:hover,
.schedule-visual-context-menu button:focus-visible {
outline: none;
background: var(--bg-hover);
}
.schedule-visual-context-menu button.danger {
color: var(--error);
}
#schedule-move-slot-modal .schedule-move-slot-card {
width: min(620px, calc(100vw - 2rem));
box-sizing: border-box;
margin: auto;
max-height: none;
padding: 1rem;
overflow: visible;
}
.schedule-move-slot-form {
display: grid;
gap: 0.55rem;
}
@media (max-width: 980px) {
.schedule-conflict-slot-row {
grid-template-columns: minmax(180px, 1.2fr) repeat(2, minmax(140px, 1fr));
}
}
.subject-import-rows-list { .subject-import-rows-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -544,7 +920,7 @@
.subject-import-row { .subject-import-row {
display: grid; display: grid;
grid-template-columns: 160px minmax(260px, 1fr) auto; grid-template-columns: minmax(120px, 0.7fr) minmax(240px, 1.3fr) auto;
column-gap: 0.65rem; column-gap: 0.65rem;
row-gap: 0.75rem; row-gap: 0.75rem;
align-items: end; align-items: end;
@@ -691,11 +1067,27 @@
min-width: 0; min-width: 0;
} }
.subject-import-row .btn {
min-height: 42px;
}
.subject-import-row .form-group label { .subject-import-row .form-group label {
margin-bottom: 0.45rem; margin-bottom: 0.45rem;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.department-workspace-grid-wide {
grid-template-columns: 1fr;
}
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card,
.department-teachers-card {
grid-column: 1;
grid-row: auto;
}
.academic-year-semesters { .academic-year-semesters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -705,6 +1097,57 @@
} }
} }
@media (max-width: 1280px) and (min-width: 901px) {
.department-workspace-grid-wide {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.department-subject-import-card,
.department-teachers-card {
grid-column: 1 / -1;
grid-row: auto;
}
.department-teacher-assign-card,
.department-teacher-request-card {
grid-column: span 1;
}
}
@media (max-width: 700px) {
.department-workspace-filter-grid {
grid-template-columns: 1fr;
}
.subject-import-row {
grid-template-columns: 1fr;
}
.department-subject-import-card .stack-form > .btn,
.department-teacher-assign-card .stack-form > .btn,
.department-teacher-request-card .stack-form > .btn,
.subject-import-row .btn {
width: 100%;
}
#schedule-conflict-modal .schedule-conflict-card {
padding: 1rem;
}
.schedule-conflict-rules,
.schedule-conflict-slot-row {
grid-template-columns: 1fr;
}
.schedule-conflict-actions {
flex-direction: column-reverse;
}
.schedule-conflict-actions .btn {
width: 100%;
}
}
.academic-year-semester-meta { .academic-year-semester-meta {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
@@ -1117,31 +1560,31 @@
.schedule-visual-edit-rule { .schedule-visual-edit-rule {
position: absolute; position: absolute;
top: 0.26rem; top: 0.18rem;
right: 0.26rem; right: 0.2rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 1.1rem; width: 1rem;
height: 1.1rem; height: 1.25rem;
padding: 0; padding: 0;
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border)); border: 0;
border-radius: 50%; border-radius: 0;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)); background: transparent;
color: var(--accent-hover); color: var(--text-secondary);
font: inherit; font: inherit;
font-size: 0.82rem; font-size: 1rem;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition); box-shadow: none;
transition: color var(--transition), transform var(--transition);
} }
.schedule-visual-edit-rule:hover, .schedule-visual-edit-rule:hover,
.schedule-visual-edit-rule:focus-visible { .schedule-visual-edit-rule:focus-visible {
border-color: var(--accent); background: transparent;
background: color-mix(in srgb, var(--accent) 22%, var(--bg-card)); color: var(--accent-hover);
color: var(--text-primary);
outline: none; outline: none;
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -1181,7 +1624,8 @@
} }
} }
.academic-calendar-tabs { .academic-calendar-tabs,
.university-structure-tabs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem; gap: 0.4rem;
@@ -1189,7 +1633,8 @@
border-bottom: 1px solid var(--bg-card-border); border-bottom: 1px solid var(--bg-card-border);
} }
.academic-calendar-tab { .academic-calendar-tab,
.university-structure-tab {
min-height: 40px; min-height: 40px;
padding: 0.55rem 0.9rem; padding: 0.55rem 0.9rem;
border: 1px solid transparent; border: 1px solid transparent;
@@ -1204,24 +1649,29 @@
} }
.academic-calendar-tab:hover, .academic-calendar-tab:hover,
.academic-calendar-tab:focus-visible { .academic-calendar-tab:focus-visible,
.university-structure-tab:hover,
.university-structure-tab:focus-visible {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-hover); background: var(--bg-hover);
outline: none; outline: none;
} }
.academic-calendar-tab.active { .academic-calendar-tab.active,
.university-structure-tab.active {
color: var(--accent-hover); color: var(--accent-hover);
background: var(--bg-card); background: var(--bg-card);
border-color: var(--bg-card-border); border-color: var(--bg-card-border);
} }
.academic-calendar-tab-panel { .academic-calendar-tab-panel,
.university-structure-tab-panel {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
.academic-calendar-tab-panel[hidden] { .academic-calendar-tab-panel[hidden],
.university-structure-tab-panel[hidden] {
display: none; display: none;
} }
@@ -1834,7 +2284,7 @@
@media (max-width: 1350px) { @media (max-width: 1350px) {
.schedule-view-filter-grid, .schedule-view-filter-grid,
.schedule-view-metrics, .schedule-view-metrics,
.department-workspace-grid { .department-workspace-grid:not(.department-workspace-grid-wide) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1917,7 +2367,8 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.academic-calendar-tab { .academic-calendar-tab,
.university-structure-tab {
flex: 1 1 100%; flex: 1 1 100%;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
@@ -2220,6 +2671,86 @@ input[type="number"] {
box-shadow: 0 0 0 3px var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-glow);
} }
.filter-row .custom-multi-select {
width: min(240px, 100%);
min-width: 220px;
}
.filter-row .select-box {
min-height: 38px;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
}
.filter-row .select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subgroup-config-grid {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
grid-template-areas:
"count capacities"
"actions actions";
gap: 1rem 0.75rem;
align-items: end;
}
.subgroup-count-field {
grid-area: count;
min-width: 0;
margin-bottom: 0;
}
.subgroup-capacity-fields {
grid-area: capacities;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
min-width: 0;
}
.subgroup-capacity-fields:empty {
display: none;
}
.subgroup-capacity-fields-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.subgroup-capacity-fields-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.subgroup-capacity-field {
min-width: 0;
margin-bottom: 0;
}
.subgroup-actions {
grid-area: actions;
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0;
}
@media (max-width: 720px) {
.subgroup-config-grid {
grid-template-columns: 1fr;
grid-template-areas:
"count"
"capacities"
"actions";
}
.subgroup-capacity-fields {
grid-template-columns: 1fr;
}
}
/* ===== Custom Multi-Select ===== */ /* ===== Custom Multi-Select ===== */
.custom-multi-select { .custom-multi-select {
@@ -2345,6 +2876,17 @@ input[type="number"] {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.form-group .checkbox-group-vertical .checkbox-item,
.filter-row .checkbox-group-vertical .checkbox-item {
display: flex;
align-items: center;
margin-bottom: 0;
color: var(--text-primary);
font-size: 0.9rem;
text-transform: none;
letter-spacing: 0;
}
.checkbox-item:hover { .checkbox-item:hover {
background: var(--bg-hover); background: var(--bg-hover);
} }

View File

@@ -104,6 +104,7 @@
} }
.nav-item svg { .nav-item svg {
flex: 0 0 auto;
transition: transform var(--transition); transition: transform var(--transition);
} }
@@ -112,6 +113,38 @@
transform: scale(1.15) rotate(-5deg); transform: scale(1.15) rotate(-5deg);
} }
.nav-item span:not(.nav-badge) {
min-width: 0;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
margin-left: auto;
padding: 0 0.45rem;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 999px;
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
}
.nav-badge[hidden],
.topbar-request-badge[hidden] {
display: none !important;
}
[data-theme="light"] .nav-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.sidebar-footer { .sidebar-footer {
padding: 0.75rem; padding: 0.75rem;
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
@@ -247,7 +280,7 @@
display: none; display: none;
} }
.sidebar.collapsed .nav-item span, .sidebar.collapsed .nav-item span:not(.nav-badge),
.sidebar.collapsed .btn-settings span { .sidebar.collapsed .btn-settings span {
position: absolute; position: absolute;
left: calc(100% + 10px); left: calc(100% + 10px);
@@ -271,13 +304,13 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
} }
[data-theme="light"] .sidebar.collapsed .nav-item span, [data-theme="light"] .sidebar.collapsed .nav-item span:not(.nav-badge),
[data-theme="light"] .sidebar.collapsed .btn-settings span { [data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.sidebar.collapsed .nav-item:hover span, .sidebar.collapsed .nav-item:hover span:not(.nav-badge),
.sidebar.collapsed .btn-settings:hover span { .sidebar.collapsed .btn-settings:hover span {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
@@ -299,6 +332,16 @@
overflow: visible; overflow: visible;
} }
.sidebar.collapsed .nav-badge {
position: absolute;
top: 0.35rem;
right: 0.35rem;
min-width: 18px;
height: 18px;
padding: 0 0.3rem;
font-size: 0.65rem;
}
.sidebar.collapsed .btn-settings { .sidebar.collapsed .btn-settings {
justify-content: center; justify-content: center;
padding: 0.65rem 0; padding: 0.65rem 0;
@@ -333,9 +376,44 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
}
.topbar-title-group {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1; flex: 1;
} }
.topbar-request-badge {
min-height: 30px;
padding: 0 0.75rem;
border: 1px solid rgba(248, 113, 113, 0.26);
border-radius: 999px;
background: rgba(248, 113, 113, 0.13);
color: #fecaca;
font: inherit;
font-size: 0.8rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.topbar-request-badge:hover {
transform: translateY(-1px);
background: rgba(248, 113, 113, 0.2);
border-color: rgba(248, 113, 113, 0.42);
}
[data-theme="light"] .topbar-request-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.content { .content {
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
display: flex; display: flex;

View File

@@ -73,3 +73,82 @@
.modal-close:hover { .modal-close:hover {
color: var(--error); color: var(--error);
} }
#modal-edit-group .edit-group-modal {
width: min(980px, calc(100vw - 2rem));
max-height: none;
overflow: visible;
}
#modal-edit-group #edit-group-form {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
#modal-edit-group .edit-group-form-row {
display: grid;
gap: 1rem;
align-items: end;
}
#modal-edit-group .edit-group-form-row-main {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
#modal-edit-group .form-group {
min-width: 0;
}
#modal-edit-group .edit-group-submit {
display: flex;
align-items: flex-end;
}
#modal-edit-group .edit-group-submit .btn {
width: 100%;
}
@media (max-width: 980px) {
#modal-edit-group .edit-group-form-row-main,
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
#modal-edit-group .edit-group-form-row-main,
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: 1fr;
}
}
#schedule-conflict-modal {
padding: 0.35rem;
overflow-x: hidden;
overflow-y: auto;
}
#schedule-conflict-modal .modal-content {
width: min(1760px, calc(100vw - 0.7rem));
max-width: calc(100vw - 0.7rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}
#schedule-move-slot-modal .modal-content {
width: min(620px, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}

View File

@@ -67,6 +67,17 @@
<span>Дашборд</span> <span>Дашборд</span>
</a> </a>
<a href="#" class="nav-item" data-tab="teacher-requests">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M8 11h8" />
<path d="M8 16h5" />
</svg>
<span>Заявки</span>
<span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
</a>
<a href="#" class="nav-item" data-tab="department-workspace"> <a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -221,7 +232,10 @@
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</button> </button>
<div class="topbar-title-group">
<h1 id="page-title">Загрузка...</h1> <h1 id="page-title">Загрузка...</h1>
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
</div>
</header> </header>
<!-- Dynamic Content Injected Here --> <!-- Dynamic Content Injected Here -->

View File

@@ -63,7 +63,10 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
} }
if (!response.ok) { if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); const error = new Error(data?.message || `Ошибка HTTP: ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
} }
return data; return data;
@@ -109,18 +112,26 @@ function invalidateCache(url) {
'/api/specialties', '/api/specialties',
'/api/education-forms', '/api/education-forms',
'/api/users', '/api/users',
'/api/teacher-requests',
'/api/department/teacher-requests',
'/api/department/teachers',
'/api/equipments', '/api/equipments',
'/api/classrooms' '/api/classrooms'
]; ];
for (const prefix of cacheKeysToClear) { const affectedPrefixes = new Set(cacheKeysToClear.filter(prefix => url.includes(prefix)));
if (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()) { for (const cacheKey of cache.keys()) {
if (cacheKey.includes(prefix)) { if (cacheKey.includes(prefix)) {
cache.delete(cacheKey); cache.delete(cacheKey);
} }
} }
} }
}
} }
export function clearCache() { export function clearCache() {

View File

@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
} }
import { isAuthenticatedAsAny, logout } from './api.js'; import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -38,6 +38,14 @@ const ROUTES = {
await module.initUsers(); await module.initUsers();
} }
}, },
'teacher-requests': {
title: 'Заявки',
file: '/admin/views/teacher-requests.html',
init: async () => {
const module = await import('./views/teacher-requests.js');
await module.initTeacherRequests();
}
},
groups: { groups: {
title: 'Управление группами', title: 'Управление группами',
file: '/admin/views/groups.html', file: '/admin/views/groups.html',
@@ -115,7 +123,7 @@ const ROUTES = {
const ROLE_NAVIGATION = { const ROLE_NAVIGATION = {
ADMIN: { ADMIN: {
defaultTab: 'dashboard', defaultTab: 'dashboard',
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'] tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
}, },
EDUCATION_OFFICE: { EDUCATION_OFFICE: {
defaultTab: 'dashboard', defaultTab: 'dashboard',
@@ -143,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout'); const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main'); const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER; const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs); const allowedTabs = new Set(roleNavigation.tabs);
@@ -150,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
applyRippleEffect(); applyRippleEffect();
closeAllDropdownsOnOutsideClick(); closeAllDropdownsOnOutsideClick();
configureRoleInterface(); configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load // Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -207,6 +218,10 @@ btnLogout.addEventListener('click', async () => {
window.location.href = '/'; window.location.href = '/';
}); });
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation // Navigation
navItems.forEach(item => { navItems.forEach(item => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
@@ -271,6 +286,42 @@ function configureRoleInterface() {
document.querySelector('.logo span').textContent = firstAllowedTitle; document.querySelector('.logo span').textContent = firstAllowedTitle;
} }
async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
: countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок:', error.message);
}
}
function updateTeacherRequestBadge(element, count, label) {
if (!element) return;
const normalizedCount = Number(count) || 0;
element.textContent = label;
element.hidden = normalizedCount <= 0;
element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
document.addEventListener('teacherRequestsChanged', (event) => {
refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
});
const hashTab = window.location.hash.replace('#', ''); const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab); switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);

View File

@@ -21,8 +21,9 @@ function renderEquipmentCheckboxes(equipments, containerId, selectTextId, select
} }
container.innerHTML = equipments.map(eq => ` container.innerHTML = equipments.map(eq => `
<label class="checkbox-container"> <label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}> <input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(eq.name)}</span> <span>${escapeHtml(eq.name)}</span>
</label> </label>
`).join(''); `).join('');

View File

@@ -14,6 +14,12 @@ export async function initDepartmentWorkspace() {
const importForm = document.getElementById('department-subject-import-form'); const importForm = document.getElementById('department-subject-import-form');
const importRowsContainer = document.getElementById('subject-import-rows'); const importRowsContainer = document.getElementById('subject-import-rows');
const importAddButton = document.getElementById('subject-import-add'); 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 subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count'); const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card'); const commentsCard = document.getElementById('department-comments-card');
@@ -25,6 +31,8 @@ export async function initDepartmentWorkspace() {
const commentsCloseButton = document.getElementById('department-comments-close'); const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null; let lastTeachers = null;
let lastWorkload = null; let lastWorkload = null;
let allTeachers = [];
let teacherRequests = null;
let teachersErrorMessage = ''; let teachersErrorMessage = '';
let workloadErrorMessage = ''; let workloadErrorMessage = '';
@@ -34,6 +42,8 @@ export async function initDepartmentWorkspace() {
startInput?.addEventListener('change', handleStartDateChange); startInput?.addEventListener('change', handleStartDateChange);
endInput?.addEventListener('change', loadWorkload); endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects); importForm?.addEventListener('submit', importSubjects);
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }])); importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
importRowsContainer?.addEventListener('click', (event) => { importRowsContainer?.addEventListener('click', (event) => {
const removeBtn = event.target.closest('.subject-import-remove'); const removeBtn = event.target.closest('.subject-import-remove');
@@ -61,6 +71,7 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true; departmentField.hidden = true;
departmentSelect.value = userDepartmentId || ''; departmentSelect.value = userDepartmentId || '';
} }
await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]); renderImportRows([{ code: '', name: '' }]);
await loadWorkspace(); await loadWorkspace();
} catch (error) { } catch (error) {
@@ -74,7 +85,9 @@ export async function initDepartmentWorkspace() {
teachersErrorMessage = ''; teachersErrorMessage = '';
workloadErrorMessage = ''; workloadErrorMessage = '';
renderTeachers(); renderTeachers();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]); teacherRequests = null;
renderTeacherRequests();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
} }
async function loadSubjects() { 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) { async function openComments(subjectId, subjectName) {
commentsCard.hidden = false; commentsCard.hidden = false;
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`; commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
@@ -215,14 +290,46 @@ export async function initDepartmentWorkspace() {
lastTeachers = null; lastTeachers = null;
teachersErrorMessage = ''; teachersErrorMessage = '';
renderTeachers(); renderTeachers();
renderTeacherSelect();
try { try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`); const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
lastTeachers = Array.isArray(teachers) ? teachers : []; lastTeachers = Array.isArray(teachers) ? teachers : [];
renderTeachers(); renderTeachers();
renderTeacherSelect();
} catch (error) { } catch (error) {
lastTeachers = []; lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей'; teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers(); 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>`; 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() { function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value; return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
} }
@@ -366,6 +516,30 @@ function subjectCountLabel(count) {
return `${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) { function renderTeacherMetric(item, workloadErrorMessage) {
return ` return `
<article class="metric-card"> <article class="metric-card">

View File

@@ -10,6 +10,8 @@ async function fetchEducationForms() {
} }
} }
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export async function initGroups() { export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody'); const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form'); const createGroupForm = document.getElementById('create-group-form');
@@ -17,7 +19,10 @@ export async function initGroups() {
const newGroupDepartmentSelect = document.getElementById('new-group-department'); const newGroupDepartmentSelect = document.getElementById('new-group-department');
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
const newGroupProfileSelect = document.getElementById('new-group-profile'); const newGroupProfileSelect = document.getElementById('new-group-profile');
const filterEfSelect = document.getElementById('filter-ef'); const filterEfBox = document.getElementById('filter-ef-box');
const filterEfMenu = document.getElementById('filter-ef-menu');
const filterEfText = document.getElementById('filter-ef-text');
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
// Модалка группы // Модалка группы
const modalEditGroup = document.getElementById('modal-edit-group'); const modalEditGroup = document.getElementById('modal-edit-group');
@@ -36,8 +41,8 @@ export async function initGroups() {
const manageSubgroupForm = document.getElementById('manage-subgroup-form'); const manageSubgroupForm = document.getElementById('manage-subgroup-form');
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id'); const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id'); const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
const manageSubgroupNameInput = document.getElementById('manage-subgroup-name'); const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity'); const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
const btnSaveSubgroup = document.getElementById('btn-save-subgroup'); const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit'); const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody'); const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
@@ -61,12 +66,14 @@ export async function initGroups() {
let academicYears = []; let academicYears = [];
let calendars = []; let calendars = [];
let currentAssignments = []; let currentAssignments = [];
let activeSubgroupGroup = null;
bindEvents(); bindEvents();
await loadInitialData(); await loadInitialData();
function bindEvents() { function bindEvents() {
filterEfSelect.addEventListener('change', applyGroupFilter); initFilterEfDropdown();
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
@@ -75,6 +82,7 @@ export async function initGroups() {
// Модалка подгрупп // Модалка подгрупп
manageSubgroupForm.addEventListener('submit', saveSubgroup); manageSubgroupForm.addEventListener('submit', saveSubgroup);
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm); btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick); manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
@@ -101,6 +109,37 @@ export async function initGroups() {
}); });
} }
function initFilterEfDropdown() {
if (!filterEfBox || !filterEfMenu) return;
const toggleDropdown = (event) => {
event.stopPropagation();
const isOpen = filterEfMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (!isOpen) {
filterEfMenu.classList.add('open');
filterEfBox.classList.add('active');
filterEfBox.setAttribute('aria-expanded', 'true');
} else {
filterEfBox.setAttribute('aria-expanded', 'false');
}
};
filterEfBox.addEventListener('click', toggleDropdown);
filterEfBox.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleDropdown(event);
}
});
filterEfMenu.addEventListener('click', (event) => {
event.stopPropagation();
});
}
async function loadInitialData() { async function loadInitialData() {
try { try {
[educationForms, departments, specialties, academicYears, calendars] = await Promise.all([ [educationForms, departments, specialties, academicYears, calendars] = await Promise.all([
@@ -148,13 +187,63 @@ export async function initGroups() {
} }
function applyGroupFilter() { function applyGroupFilter() {
const filterId = filterEfSelect.value; const selectedIds = selectedFilterEducationFormIds();
const filtered = filterId const filtered = selectedIds.length
? allGroups.filter(group => group.educationFormId == filterId) ? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
: allGroups; : allGroups;
renderGroups(filtered); renderGroups(filtered);
} }
function handleFilterEfChange(event) {
const changedCheckbox = event.target.closest('input[type="checkbox"]');
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (changedCheckbox?.value === 'all' && changedCheckbox.checked) {
filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(checkbox => {
checkbox.checked = false;
});
} else if (changedCheckbox?.value !== 'all' && changedCheckbox?.checked) {
allCheckbox.checked = false;
}
if (!selectedFilterEducationFormIds().length) {
allCheckbox.checked = true;
}
updateFilterEfText();
applyGroupFilter();
}
function selectedFilterEducationFormIds() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (allCheckbox?.checked) return [];
return Array.from(filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'))
.map(checkbox => String(checkbox.value));
}
function updateFilterEfText() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
const selectedIds = selectedFilterEducationFormIds();
if (allCheckbox?.checked) {
filterEfText.textContent = 'Все';
return;
}
if (!selectedIds.length) {
filterEfText.textContent = FILTER_EF_EMPTY_TEXT;
return;
}
if (selectedIds.length === 1) {
const form = educationForms.find(item => String(item.id) === selectedIds[0]);
filterEfText.textContent = form?.name || FILTER_EF_EMPTY_TEXT;
return;
}
filterEfText.textContent = `Выбрано: ${selectedIds.length}`;
}
function renderGroups(groups) { function renderGroups(groups) {
if (!groups || !groups.length) { if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>'; groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
@@ -188,11 +277,30 @@ export async function initGroups() {
function populateEfSelects(forms) { function populateEfSelects(forms) {
populateEfSelect(newGroupEfSelect, forms); populateEfSelect(newGroupEfSelect, forms);
populateEfSelect(editGroupEfSelect, forms); populateEfSelect(editGroupEfSelect, forms);
const currentFilter = filterEfSelect.value; renderFilterEfCheckboxes(forms);
filterEfSelect.innerHTML = '<option value="">Все формы</option>' + }
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentFilter) filterEfSelect.value = currentFilter; function renderFilterEfCheckboxes(forms) {
syncSelects(filterEfSelect); const selectedIds = new Set(selectedFilterEducationFormIds());
const allChecked = !selectedIds.size;
filterEfCheckboxes.innerHTML = `
<label class="checkbox-item">
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
<span class="checkmark"></span>
<span>Все</span>
</label>
${forms.map(form => {
const id = String(form.id);
return `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(form.name)}</span>
</label>
`;
}).join('')}
`;
updateFilterEfText();
} }
function populateEfSelect(select, forms) { function populateEfSelect(select, forms) {
@@ -411,6 +519,7 @@ export async function initGroups() {
// --- Управление подгруппами --- // --- Управление подгруппами ---
function openSubgroupsModal(group) { function openSubgroupsModal(group) {
activeSubgroupGroup = group;
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`; subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
manageSubgroupGroupIdInput.value = group.id; manageSubgroupGroupIdInput.value = group.id;
resetSubgroupForm(); resetSubgroupForm();
@@ -424,13 +533,19 @@ export async function initGroups() {
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>'; manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
return; return;
} }
const canDeleteSingleSubgroup = list.length === 1;
const deleteTitle = canDeleteSingleSubgroup
? 'Удалить подгруппу'
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
manageSubgroupsTbody.innerHTML = list.map(sub => ` manageSubgroupsTbody.innerHTML = list.map(sub => `
<tr> <tr>
<td>${escapeHtml(sub.name)}</td> <td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td> <td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td> <td>
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
${canDeleteSingleSubgroup ? '' : 'disabled'}
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -438,46 +553,157 @@ export async function initGroups() {
function resetSubgroupForm() { function resetSubgroupForm() {
manageSubgroupIdInput.value = ''; manageSubgroupIdInput.value = '';
manageSubgroupNameInput.value = ''; const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
manageSubgroupCapacityInput.value = ''; const count = [2, 3].includes(current.length) ? current.length : 0;
subgroupFormTitle.textContent = 'Создание подгруппы'; manageSubgroupCountSelect.value = String(count);
btnSaveSubgroup.textContent = 'Создать'; syncSelects(manageSubgroupCountSelect);
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
subgroupFormTitle.textContent = 'Настройка подгрупп';
btnSaveSubgroup.textContent = 'Применить';
btnCancelSubgroupEdit.style.display = 'none'; btnCancelSubgroupEdit.style.display = 'none';
hideAlert('manage-subgroup-alert'); hideAlert('manage-subgroup-alert');
} }
function currentGroupSubgroups(groupId) {
return subgroups
.filter(sub => String(sub.groupId) === String(groupId))
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
}
function naturalSubgroupOrder(name) {
const number = String(name || '').match(/\d+/)?.[0];
return number ? String(number).padStart(3, '0') : String(name || '');
}
function renderSubgroupCapacityFields(count, capacities = []) {
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
if (!count) {
manageSubgroupCapacities.innerHTML = '';
return;
}
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
const suggested = suggestedSubgroupCapacities(groupSize, count);
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
const value = capacities[index] ?? suggested[index] ?? '';
return `
<div class="form-group subgroup-capacity-field">
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
</div>
`;
}).join('');
}
function suggestedSubgroupCapacities(groupSize, count) {
if (!groupSize || !count) return [];
const base = Math.floor(groupSize / count);
const remainder = groupSize % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
async function saveSubgroup(event) { async function saveSubgroup(event) {
event.preventDefault(); event.preventDefault();
hideAlert('manage-subgroup-alert'); hideAlert('manage-subgroup-alert');
const groupId = manageSubgroupGroupIdInput.value; const groupId = manageSubgroupGroupIdInput.value;
const subgroupId = manageSubgroupIdInput.value; const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
const name = manageSubgroupNameInput.value.trim(); const count = Number(manageSubgroupCountSelect.value);
const capacity = manageSubgroupCapacityInput.value; const existingSubgroups = currentGroupSubgroups(groupId);
if (!name || !capacity) { if (!group) {
showAlert('manage-subgroup-alert', 'Заполните все поля', 'error'); showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
return; return;
} }
const payload = { if (count === 0) {
name, await applyNoSubgroups(groupId, existingSubgroups);
studentCapacity: Number(capacity) return;
}; }
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
.map(input => Number(input.value));
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
if (validationError) {
showAlert('manage-subgroup-alert', validationError, 'error');
return;
}
try { try {
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`; await syncSubgroups(groupId, existingSubgroups, capacities);
const saved = subgroupId showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
? await api.put(endpoint, payload)
: await api.post(endpoint, payload);
showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
resetSubgroupForm();
await loadSubgroups(); await loadSubgroups();
resetSubgroupForm();
renderSubgroupsList(groupId); renderSubgroupsList(groupId);
} catch (error) { } catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error'); showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
} }
} }
function validateSubgroupCapacities(capacities, groupSize, count) {
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
return 'Укажите положительную численность для каждой подгруппы';
}
const total = capacities.reduce((sum, value) => sum + value, 0);
if (total !== groupSize) {
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
}
return null;
}
async function applyNoSubgroups(groupId, existingSubgroups) {
try {
for (const subgroup of existingSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
await loadSubgroups();
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка отключения подгрупп', 'error');
}
}
async function syncSubgroups(groupId, existingSubgroups, capacities) {
const targetCount = capacities.length;
const extraSubgroups = existingSubgroups.slice(targetCount);
for (const subgroup of extraSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
const updates = [];
const creates = [];
for (let index = 0; index < targetCount; index += 1) {
const payload = {
name: `Подгруппа ${index + 1}`,
studentCapacity: capacities[index]
};
const existing = existingSubgroups[index];
if (existing) {
updates.push({ existing, payload });
} else {
creates.push(payload);
}
}
updates.sort((left, right) => {
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
return Number(leftIncreases) - Number(rightIncreases);
});
for (const update of updates) {
await api.put(`/api/groups/${groupId}/subgroups/${update.existing.id}`, update.payload);
}
for (const payload of creates) {
await api.post(`/api/groups/${groupId}/subgroups`, payload);
}
}
async function handleSubgroupTableClick(event) { async function handleSubgroupTableClick(event) {
const editButton = event.target.closest('.btn-edit-subgroup'); const editButton = event.target.closest('.btn-edit-subgroup');
const deleteButton = event.target.closest('.btn-delete-subgroup'); const deleteButton = event.target.closest('.btn-delete-subgroup');
@@ -487,17 +713,19 @@ export async function initGroups() {
const subgroup = subgroups.find(item => item.id == editButton.dataset.id); const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
if (!subgroup) return; if (!subgroup) return;
manageSubgroupIdInput.value = subgroup.id; resetSubgroupForm();
manageSubgroupNameInput.value = subgroup.name || ''; const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? ''; const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
subgroupFormTitle.textContent = 'Редактирование подгруппы'; input?.focus();
btnSaveSubgroup.textContent = 'Сохранить';
btnCancelSubgroupEdit.style.display = 'inline-block';
hideAlert('manage-subgroup-alert'); hideAlert('manage-subgroup-alert');
return; return;
} }
if (deleteButton) { if (deleteButton) {
if (deleteButton.disabled) {
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
return;
}
if (!confirm('Удалить подгруппу?')) return; if (!confirm('Удалить подгруппу?')) return;
try { try {
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`); await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);

View File

@@ -87,6 +87,28 @@ export async function initSchedule() {
const visualGroupFilter = document.getElementById('schedule-visual-group-filter'); const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
const visualUseFormButton = document.getElementById('schedule-visual-use-form'); const visualUseFormButton = document.getElementById('schedule-visual-use-form');
const visualSelectAllButton = document.getElementById('schedule-visual-select-all'); const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
const conflictModal = document.getElementById('schedule-conflict-modal');
const conflictForm = document.getElementById('schedule-conflict-form');
const conflictCloseButton = document.getElementById('schedule-conflict-close');
const conflictCancelButton = document.getElementById('schedule-conflict-cancel');
const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
const conflictSummary = document.getElementById('schedule-conflict-summary');
const conflictReasons = document.getElementById('schedule-conflict-reasons');
const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
const conflictExistingRuleMeta = document.getElementById('schedule-conflict-existing-rule-meta');
const visualContextMenu = document.getElementById('schedule-visual-context-menu');
const moveSlotModal = document.getElementById('schedule-move-slot-modal');
const moveSlotForm = document.getElementById('schedule-move-slot-form');
const moveSlotCloseButton = document.getElementById('schedule-move-slot-close');
const moveSlotCancelButton = document.getElementById('schedule-move-slot-cancel');
const moveSlotSubmitButton = document.getElementById('schedule-move-slot-submit');
const moveSlotTitle = document.getElementById('schedule-move-slot-title');
const moveSlotSummary = document.getElementById('schedule-move-slot-summary');
const moveSlotDaySelect = document.getElementById('schedule-move-slot-day');
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
let rules = []; let rules = [];
let subjects = []; let subjects = [];
@@ -104,6 +126,9 @@ export async function initSchedule() {
let visualGroupFilterTouched = false; let visualGroupFilterTouched = false;
let visualPeriodTouched = false; let visualPeriodTouched = false;
let syncingVisualPeriod = false; let syncingVisualPeriod = false;
let pendingConflictResolution = null;
let visualContextTarget = null;
let pendingSlotMove = null;
bindEvents(); bindEvents();
@@ -127,6 +152,19 @@ export async function initSchedule() {
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange); visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange); visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualStage?.addEventListener('click', handleVisualStageClick); visualStage?.addEventListener('click', handleVisualStageClick);
conflictCloseButton?.addEventListener('click', closeConflictModal);
conflictCancelButton?.addEventListener('click', closeConflictModal);
conflictModal?.addEventListener('click', (event) => {
if (event.target === conflictModal) closeConflictModal();
});
conflictForm?.addEventListener('submit', resolveScheduleRuleConflict);
visualContextMenu?.addEventListener('click', handleVisualContextMenuClick);
moveSlotCloseButton?.addEventListener('click', closeMoveSlotModal);
moveSlotCancelButton?.addEventListener('click', closeMoveSlotModal);
moveSlotModal?.addEventListener('click', (event) => {
if (event.target === moveSlotModal) closeMoveSlotModal();
});
moveSlotForm?.addEventListener('submit', saveVisualSlotMove);
visualUseFormButton?.addEventListener('click', () => { visualUseFormButton?.addEventListener('click', () => {
const formSemesterId = ruleSemesterSelect.value; const formSemesterId = ruleSemesterSelect.value;
if (formSemesterId) { if (formSemesterId) {
@@ -148,7 +186,25 @@ export async function initSchedule() {
renderVisualSchedule(); renderVisualSchedule();
}); });
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeVisualDrawer(); if (event.key !== 'Escape') return;
if (moveSlotModal?.classList.contains('open')) {
closeMoveSlotModal();
return;
}
if (conflictModal?.classList.contains('open')) {
closeConflictModal();
return;
}
if (visualContextMenu && !visualContextMenu.hidden) {
closeVisualContextMenu();
return;
}
closeVisualDrawer();
});
document.addEventListener('click', (event) => {
if (visualContextMenu?.hidden) return;
if (event.target.closest('#schedule-visual-context-menu') || event.target.closest('.schedule-visual-edit-rule')) return;
closeVisualContextMenu();
}); });
ruleSlotsContainer.addEventListener('click', (event) => { ruleSlotsContainer.addEventListener('click', (event) => {
@@ -401,6 +457,10 @@ export async function initSchedule() {
const lesson = { const lesson = {
id: `${rule.id}:${slotKey}:${groupId}`, id: `${rule.id}:${slotKey}:${groupId}`,
ruleId: rule.id, ruleId: rule.id,
slotId: slot.id || '',
slotIndex: index,
dayOfWeek: slot.dayOfWeek,
timeSlotId: slot.timeSlotId,
parity: slot.parity || 'BOTH', parity: slot.parity || 'BOTH',
subjectName: rule.subjectName || 'Без дисциплины', subjectName: rule.subjectName || 'Без дисциплины',
teacherName: slot.teacherName || '', teacherName: slot.teacherName || '',
@@ -498,8 +558,10 @@ export async function initSchedule() {
<button type="button" <button type="button"
class="schedule-visual-edit-rule" class="schedule-visual-edit-rule"
data-rule-id="${escapeHtml(String(lesson.ruleId))}" data-rule-id="${escapeHtml(String(lesson.ruleId))}"
title="Изменить правило" data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
aria-label="${escapeHtml(`Изменить правило: ${lesson.subjectName}`)}">&#8942;</button> data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
title="Действия со слотом"
aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">&#8942;</button>
</article> </article>
`; `;
} }
@@ -580,14 +642,197 @@ export async function initSchedule() {
function handleVisualStageClick(event) { function handleVisualStageClick(event) {
const editButton = event.target.closest('.schedule-visual-edit-rule'); const editButton = event.target.closest('.schedule-visual-edit-rule');
if (!editButton) return; if (!editButton) return;
const rule = rules.find(item => String(item.id) === String(editButton.dataset.ruleId)); event.stopPropagation();
openVisualContextMenu(editButton);
}
function openVisualContextMenu(button) {
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
if (!rule) { if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return; return;
} }
visualContextTarget = {
ruleId: button.dataset.ruleId,
slotId: button.dataset.slotId || '',
slotIndex: button.dataset.slotIndex || ''
};
if (!visualContextMenu) return;
const rect = button.getBoundingClientRect();
visualContextMenu.hidden = false;
const menuRect = visualContextMenu.getBoundingClientRect();
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
visualContextMenu.style.top = `${Math.max(12, top)}px`;
visualContextMenu.style.left = `${Math.max(12, left)}px`;
visualContextMenu.querySelector('button')?.focus();
}
function closeVisualContextMenu() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenu.style.top = '';
visualContextMenu.style.left = '';
}
async function handleVisualContextMenuClick(event) {
const actionButton = event.target.closest('button[data-action]');
if (!actionButton || !visualContextTarget) return;
const action = actionButton.dataset.action;
const target = { ...visualContextTarget };
const rule = rules.find(item => String(item.id) === String(target.ruleId));
if (!rule) {
closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
}
if (action === 'edit-rule') {
closeVisualContextMenu();
fillRuleForm(rule); fillRuleForm(rule);
closeVisualDrawer(); closeVisualDrawer();
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success'); showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
return;
}
if (action === 'move-slot') {
openMoveSlotModal(target);
return;
}
if (action === 'delete-rule') {
await deleteRuleFromVisual(rule);
}
}
async function deleteRuleFromVisual(rule) {
closeVisualContextMenu();
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
try {
await api.delete('/api/admin/schedule-rules/' + rule.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
}
}
function openMoveSlotModal(target) {
const rule = rules.find(item => String(item.id) === String(target.ruleId));
const slotIndex = findRuleSlotIndex(rule, target);
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
if (!rule || !slot) {
closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Слот правила не найден. Обновите список правил.', 'error');
return;
}
pendingSlotMove = {
ruleId: String(rule.id),
slotIndex
};
if (moveSlotTitle) {
moveSlotTitle.textContent = rule.subjectName || 'Изменить день и пару';
}
if (moveSlotSummary) {
moveSlotSummary.textContent = `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`;
}
if (moveSlotDaySelect) {
moveSlotDaySelect.innerHTML = options(DAY_OPTIONS, slot.dayOfWeek);
}
if (moveSlotTimeSelect) {
moveSlotTimeSelect.innerHTML = options(timeSlots.map(toTimeSlotOption), slot.timeSlotId);
}
hideAlert('schedule-move-slot-alert');
closeVisualContextMenu();
moveSlotModal?.classList.add('open');
moveSlotModal?.setAttribute('aria-hidden', 'false');
moveSlotDaySelect?.focus();
}
function closeMoveSlotModal() {
pendingSlotMove = null;
moveSlotModal?.classList.remove('open');
moveSlotModal?.setAttribute('aria-hidden', 'true');
hideAlert('schedule-move-slot-alert');
}
async function saveVisualSlotMove(event) {
event.preventDefault();
if (!pendingSlotMove) return;
hideAlert('schedule-move-slot-alert');
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
if (!rule || !slot) {
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
return;
}
const dayOfWeek = Number(moveSlotDaySelect?.value);
const timeSlotId = Number(moveSlotTimeSelect?.value);
if (!dayOfWeek || !timeSlotId) {
showAlert('schedule-move-slot-alert', 'Выберите день и пару', 'error');
return;
}
const previousButtonText = moveSlotSubmitButton?.textContent || '';
if (moveSlotSubmitButton) {
moveSlotSubmitButton.disabled = true;
moveSlotSubmitButton.textContent = 'Сохраняем...';
}
try {
const slots = (rule.slots || []).map(ruleSlotToPayload);
slots[pendingSlotMove.slotIndex] = {
...slots[pendingSlotMove.slotIndex],
dayOfWeek,
timeSlotId
};
const payload = buildRulePayloadFromRule(rule, slots);
const saved = await api.put('/api/admin/schedule-rules/' + rule.id, payload);
closeMoveSlotModal();
showAlert('schedule-rule-alert', `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, 'success');
await loadRules();
} catch (error) {
if (isScheduleRuleConflictError(error)) {
showAlert(
'schedule-move-slot-alert',
`Новое место занято правилом "${ruleTitle(error.data.conflictRule)}". Выберите другой день или пару.`,
'error'
);
return;
}
showAlert('schedule-move-slot-alert', error.message || 'Ошибка переноса слота', 'error');
} finally {
if (moveSlotSubmitButton) {
moveSlotSubmitButton.disabled = false;
moveSlotSubmitButton.textContent = previousButtonText || 'Сохранить перенос';
}
}
}
function findRuleSlotIndex(rule, target) {
if (!rule?.slots?.length) return -1;
if (target.slotId) {
const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId));
if (byId >= 0) return byId;
}
const index = Number(target.slotIndex);
return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1;
}
function ruleSlotToPayload(slot) {
return {
id: slot.id || null,
dayOfWeek: Number(slot.dayOfWeek),
parity: slot.parity || 'BOTH',
timeSlotId: Number(slot.timeSlotId),
subgroupId: slot.subgroupId || null,
subgroupIds: slotSubgroupIds(slot),
teacherId: Number(slot.teacherId),
classroomId: Number(slot.classroomId),
lessonTypeId: Number(slot.lessonTypeId),
lessonFormat: slot.lessonFormat || 'Очно'
};
} }
function syncVisualGroupFilter(allGroups) { function syncVisualGroupFilter(allGroups) {
@@ -770,20 +1015,302 @@ export async function initSchedule() {
async function saveRule(event) { async function saveRule(event) {
event.preventDefault(); event.preventDefault();
hideAlert('schedule-rule-alert'); hideAlert('schedule-rule-alert');
let payload = null;
let id = '';
try { try {
const payload = buildRulePayload(); payload = buildRulePayload();
const id = ruleIdInput.value; id = ruleIdInput.value;
const saved = id const saved = await saveRulePayload(id, payload);
? await api.put('/api/admin/schedule-rules/' + id, payload) await finishSuccessfulRuleSave(saved);
: await api.post('/api/admin/schedule-rules', payload);
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
resetRuleForm();
await loadRules();
} catch (error) { } catch (error) {
if (payload && isScheduleRuleConflictError(error)) {
openConflictModal(error, payload, id);
return;
}
showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error'); showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
} }
} }
async function saveRulePayload(id, payload) {
return id
? api.put('/api/admin/schedule-rules/' + id, payload)
: api.post('/api/admin/schedule-rules', payload);
}
async function finishSuccessfulRuleSave(saved) {
resetRuleForm();
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
await loadRules();
}
function openConflictModal(error, pendingPayload, pendingRuleId) {
const conflictRule = error.data.conflictRule;
pendingConflictResolution = {
conflictRule: clonePlain(conflictRule),
conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [],
pendingPayload: clonePlain(pendingPayload),
pendingRuleId: pendingRuleId || ''
};
if (conflictSummary) {
conflictSummary.textContent = error.message || 'Ранее созданное правило занимает выбранный слот.';
}
if (conflictNewRuleTitle) {
conflictNewRuleTitle.textContent = ruleDraftTitle(pendingPayload);
}
if (conflictNewRuleMeta) {
conflictNewRuleMeta.textContent = ruleDraftMeta(pendingPayload);
}
if (conflictExistingRuleTitle) {
conflictExistingRuleTitle.textContent = ruleTitle(conflictRule);
}
if (conflictExistingRuleMeta) {
conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
}
renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons);
renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields);
hideAlert('schedule-conflict-alert');
conflictModal?.classList.add('open');
conflictModal?.setAttribute('aria-hidden', 'false');
}
function closeConflictModal() {
pendingConflictResolution = null;
conflictModal?.classList.remove('open');
conflictModal?.setAttribute('aria-hidden', 'true');
if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
if (conflictReasons) {
conflictReasons.hidden = true;
conflictReasons.innerHTML = '';
}
hideAlert('schedule-conflict-alert');
}
async function resolveScheduleRuleConflict(event) {
event.preventDefault();
if (!pendingConflictResolution) return;
hideAlert('schedule-conflict-alert');
const previousButtonText = conflictSubmitButton?.textContent || '';
if (conflictSubmitButton) {
conflictSubmitButton.disabled = true;
conflictSubmitButton.textContent = 'Сохраняем...';
}
try {
const currentResolution = pendingConflictResolution;
const conflictPayload = buildRulePayloadFromRule(
currentResolution.conflictRule,
readConflictSlots(true)
);
await api.put('/api/admin/schedule-rules/' + currentResolution.conflictRule.id, conflictPayload);
try {
const saved = await saveRulePayload(
currentResolution.pendingRuleId,
currentResolution.pendingPayload
);
closeConflictModal();
await finishSuccessfulRuleSave(saved);
} catch (retryError) {
if (isScheduleRuleConflictError(retryError)) {
await loadRules();
openConflictModal(
retryError,
currentResolution.pendingPayload,
currentResolution.pendingRuleId
);
showAlert(
'schedule-conflict-alert',
'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.',
'error'
);
return;
}
throw retryError;
}
} catch (error) {
if (isScheduleRuleConflictError(error)) {
showAlert(
'schedule-conflict-alert',
`Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule)}. Выберите другой день, пару, преподавателя или аудиторию.`,
'error'
);
return;
}
showAlert('schedule-conflict-alert', error.message || 'Ошибка разрешения конфликта', 'error');
} finally {
if (conflictSubmitButton) {
conflictSubmitButton.disabled = false;
conflictSubmitButton.textContent = previousButtonText || 'Сохранить и повторить';
}
}
}
function renderConflictReasons(fields, reasons) {
if (!conflictReasons) return;
const labels = Array.isArray(reasons) && reasons.length
? reasons
: (fields || []).map(conflictFieldLabel).filter(Boolean);
if (!labels.length) {
conflictReasons.hidden = true;
conflictReasons.innerHTML = '';
return;
}
conflictReasons.hidden = false;
conflictReasons.innerHTML = `
<span>Причина:</span>
${labels.map(label => `<strong>${escapeHtml(label)}</strong>`).join('')}
`;
}
function renderConflictSlots(rule, conflictFields = []) {
const slots = rule.slots || [];
if (!conflictSlotsContainer) return;
if (!slots.length) {
conflictSlotsContainer.innerHTML = '<div class="loading-row">У конфликтующего правила нет слотов</div>';
return;
}
conflictSlotsContainer.innerHTML = slots.map((slot, index) => {
const fixedParts = [
slot.lessonTypeName,
slot.lessonFormat,
slotSubgroupNames(slot).join(', ')
].filter(Boolean);
const groupHitClass = conflictFieldClass('group', conflictFields);
return `
<section class="schedule-conflict-slot-row" data-index="${index}">
<div class="schedule-conflict-slot-fixed ${groupHitClass}">
<span>Слот ${index + 1}</span>
<strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
<small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
</div>
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
</section>
`;
}).join('');
}
function conflictFieldClass(field, fields) {
return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
}
function conflictFieldLabel(field) {
if (field === 'teacher') return 'Преподаватель';
if (field === 'classroom') return 'Аудитория';
if (field === 'group') return 'Группа';
return field;
}
function readConflictSlots(validate) {
const conflictRule = pendingConflictResolution?.conflictRule;
const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
return rows.map((row, index) => {
const original = conflictRule?.slots?.[index] || {};
const slot = {
id: original.id || null,
dayOfWeek: Number(row.querySelector('.conflict-slot-day').value),
parity: row.querySelector('.conflict-slot-parity').value,
timeSlotId: Number(row.querySelector('.conflict-slot-time').value),
subgroupId: original.subgroupId || null,
subgroupIds: slotSubgroupIds(original),
teacherId: Number(row.querySelector('.conflict-slot-teacher').value),
classroomId: Number(row.querySelector('.conflict-slot-classroom').value),
lessonTypeId: Number(original.lessonTypeId),
lessonFormat: original.lessonFormat || 'Очно'
};
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1} конфликтующего правила`);
}
return slot;
});
}
function buildRulePayloadFromRule(rule, slots) {
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
return {
id: Number(rule.id),
subjectId: Number(rule.subjectId),
semesterId: Number(rule.semesterId),
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
lectureStartWeek: Number(rule.lectureStartWeek || 1),
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
practiceStartWeek: Number(rule.practiceStartWeek || 1),
groupIds,
slots
};
}
function isScheduleRuleConflictError(error) {
return error?.status === 409 && Boolean(error.data?.conflictRule);
}
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
function ruleDraftTitle(payload) {
const subject = subjects.find(item => String(item.id) === String(payload.subjectId));
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
}
function ruleTitle(rule) {
return rule?.subjectName || ruleDraftTitle(rule || {});
}
function ruleDraftMeta(payload) {
const groupNames = (payload.groupIds || []).map(groupNameById).join(', ') || 'группы не выбраны';
return [semesterTextById(payload.semesterId), groupNames, typeLoadText(payload)]
.filter(Boolean)
.join(' · ');
}
function ruleMeta(rule) {
const groupNames = (rule.groupNames || []).join(', ')
|| (rule.groupIds || []).map(groupNameById).join(', ')
|| 'группы не выбраны';
return [formatSemester(rule), groupNames, typeLoadText(rule)]
.filter(Boolean)
.join(' · ');
}
function typeLoadText(rule) {
return LESSON_TYPE_LIMITS
.map(config => {
const hours = Number(rule?.[config.hoursField] || 0);
const startWeek = Number(rule?.[config.startWeekField] || 1);
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
})
.filter(Boolean)
.join('; ');
}
function semesterTextById(semesterId) {
const semester = semesterById(semesterId);
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
}
function conflictSlotCurrentText(slot) {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
return [
slot.dayName,
slot.timeSlotLabel,
parity,
slot.teacherName,
slot.classroomName
].filter(Boolean).join(' · ');
}
function buildRulePayload() { function buildRulePayload() {
const groupIds = selectedRuleGroupIds(); const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true); const slots = readRuleSlots(true);

View File

@@ -0,0 +1,136 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
const refreshButton = document.getElementById('teacher-requests-refresh');
const requestsCount = document.getElementById('teacher-requests-count');
let departments = [];
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
requestsTbody.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');
const normalizedRequests = Array.isArray(requests) ? requests : [];
renderRequests(normalizedRequests);
updateCount(normalizedRequests.length);
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
requestsTbody.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 updateCount(count) {
if (!requestsCount) return;
requestsCount.textContent = requestCountLabel(count);
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
async function approveRequest(row, requestId) {
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 loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
}
async function rejectRequest(requestId) {
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
}
refreshButton?.addEventListener('click', loadRequests);
requestsTbody.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) {
await approveRequest(row, requestId);
return;
}
await rejectRequest(requestId);
});
try {
await loadDepartments();
await loadRequests();
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
}
}

View File

@@ -53,41 +53,26 @@ export async function initUniversityStructure() {
let allProfiles = []; let allProfiles = [];
// --- Логика переключения табов --- // --- Логика переключения табов ---
function deactivateTabs() { const structureTabs = [
[tabDepartments, tabSpecialties, tabProfiles].forEach(tab => { { button: tabDepartments, panel: contentDepartments },
if (tab) { { button: tabSpecialties, panel: contentSpecialties },
tab.classList.remove('active'); { button: tabProfiles, panel: contentProfiles },
tab.style.borderBottomColor = 'transparent'; ];
tab.style.color = 'var(--text-secondary)';
function activateStructureTab(activeButton) {
structureTabs.forEach(({ button, panel }) => {
const active = button === activeButton;
button?.classList.toggle('active', active);
button?.setAttribute('aria-selected', active ? 'true' : 'false');
if (panel) {
panel.classList.toggle('active', active);
panel.hidden = !active;
} }
}); });
[contentDepartments, contentSpecialties, contentProfiles].forEach(content => {
if (content) content.style.display = 'none';
});
} }
tabDepartments.addEventListener('click', () => { structureTabs.forEach(({ button }) => {
deactivateTabs(); button?.addEventListener('click', () => activateStructureTab(button));
tabDepartments.classList.add('active');
tabDepartments.style.borderBottomColor = 'var(--primary)';
tabDepartments.style.color = 'var(--text-main)';
contentDepartments.style.display = 'block';
});
tabSpecialties.addEventListener('click', () => {
deactivateTabs();
tabSpecialties.classList.add('active');
tabSpecialties.style.borderBottomColor = 'var(--primary)';
tabSpecialties.style.color = 'var(--text-main)';
contentSpecialties.style.display = 'block';
});
tabProfiles.addEventListener('click', () => {
deactivateTabs();
tabProfiles.classList.add('active');
tabProfiles.style.borderBottomColor = 'var(--primary)';
tabProfiles.style.color = 'var(--text-main)';
contentProfiles.style.display = 'block';
}); });
// --- Загрузка данных --- // --- Загрузка данных ---

View File

@@ -71,6 +71,10 @@ export async function initUsers() {
showAlert('create-alert', 'Заполните все поля', 'error'); showAlert('create-alert', 'Заполните все поля', 'error');
return; return;
} }
if (password.length < 8) {
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try { try {
const data = await api.post('/api/users', { const data = await api.post('/api/users', {

View File

@@ -1,10 +1,10 @@
<div class="card"> <div class="card department-workspace-control-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Рабочая область кафедры</h2> <h2>Рабочая область кафедры</h2>
<button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button> <button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button>
</div> </div>
<div class="schedule-view-filter-grid"> <div class="department-workspace-filter-grid">
<div class="form-group" id="department-workspace-department-field"> <div class="form-group" id="department-workspace-department-field">
<label for="department-workspace-department">Кафедра</label> <label for="department-workspace-department">Кафедра</label>
<select id="department-workspace-department" data-native-select="true"> <select id="department-workspace-department" data-native-select="true">
@@ -24,8 +24,8 @@
<div class="form-alert" id="department-workspace-alert" role="alert"></div> <div class="form-alert" id="department-workspace-alert" role="alert"></div>
</div> </div>
<div class="department-workspace-grid"> <div class="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
<div class="card create-card"> <div class="card create-card department-subject-import-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Загрузка дисциплин</h2> <h2>Загрузка дисциплин</h2>
</div> </div>
@@ -42,7 +42,53 @@
</form> </form>
</div> </div>
<div class="card"> <div class="card create-card department-teacher-assign-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 department-teacher-request-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 department-teachers-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Преподаватели и нагрузка</h2> <h2>Преподаватели и нагрузка</h2>
</div> </div>
@@ -50,7 +96,31 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card department-workspace-table-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 department-workspace-table-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Дисциплины кафедры</h2> <h2>Дисциплины кафедры</h2>
<span class="muted" id="department-subject-count">0 дисциплин</span> <span class="muted" id="department-subject-count">0 дисциплин</span>
@@ -72,7 +142,7 @@
</div> </div>
</div> </div>
<div class="card" id="department-comments-card" hidden> <div class="card department-workspace-table-card" id="department-comments-card" hidden>
<div class="card-header-row"> <div class="card-header-row">
<h2 id="department-comments-title">Комментарии</h2> <h2 id="department-comments-title">Комментарии</h2>
<button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button> <button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button>

View File

@@ -49,10 +49,26 @@
<div class="card-header-row"> <div class="card-header-row">
<h2>Все группы</h2> <h2>Все группы</h2>
<div class="filter-row"> <div class="filter-row">
<label for="filter-ef">Фильтр:</label> <label for="filter-ef-box">Фильтр:</label>
<select id="filter-ef"> <div class="custom-multi-select">
<option value="">Все формы</option> <div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
</select> <span class="select-text" id="filter-ef-text">Выберите форму обучения</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="filter-ef-menu">
<div id="filter-ef-checkboxes" class="checkbox-group-vertical">
<label class="checkbox-item">
<input type="checkbox" value="all" checked>
<span class="checkmark"></span>
<span>Все</span>
</label>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
@@ -82,12 +98,12 @@
<!-- Модальное окно редактирования группы --> <!-- Модальное окно редактирования группы -->
<div class="modal-overlay" id="modal-edit-group"> <div class="modal-overlay" id="modal-edit-group">
<div class="modal-content card"> <div class="modal-content card edit-group-modal">
<h2>Редактировать группу</h2> <h2>Редактировать группу</h2>
<button class="modal-close" id="modal-edit-group-close">&times;</button> <button class="modal-close" id="modal-edit-group-close">&times;</button>
<form id="edit-group-form"> <form id="edit-group-form">
<input type="hidden" id="edit-group-id"> <input type="hidden" id="edit-group-id">
<div class="form-row" style="margin-top: 1rem;"> <div class="form-row edit-group-form-row edit-group-form-row-main">
<div class="form-group"> <div class="form-group">
<label for="edit-group-name">Название группы</label> <label for="edit-group-name">Название группы</label>
<input type="text" id="edit-group-name" required> <input type="text" id="edit-group-name" required>
@@ -103,7 +119,7 @@
</select> </select>
</div> </div>
</div> </div>
<div class="form-row" style="margin-top: 1rem;"> <div class="form-row edit-group-form-row edit-group-form-row-details">
<div class="form-group"> <div class="form-group">
<label for="edit-group-department">Кафедра</label> <label for="edit-group-department">Кафедра</label>
<select id="edit-group-department"> <select id="edit-group-department">
@@ -126,8 +142,8 @@
<option value="">Выберите специальность</option> <option value="">Выберите специальность</option>
</select> </select>
</div> </div>
<div class="form-group" style="display: flex; align-items: flex-end;"> <div class="form-group edit-group-submit">
<button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button> <button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div> </div>
</div> </div>
<div class="form-alert" id="edit-group-alert" role="alert"></div> <div class="form-alert" id="edit-group-alert" role="alert"></div>
@@ -152,17 +168,18 @@
<form id="manage-subgroup-form"> <form id="manage-subgroup-form">
<input type="hidden" id="manage-subgroup-id"> <input type="hidden" id="manage-subgroup-id">
<input type="hidden" id="manage-subgroup-group-id"> <input type="hidden" id="manage-subgroup-group-id">
<div class="form-row" style="align-items: flex-end;"> <div class="subgroup-config-grid">
<div class="form-group" style="margin-bottom: 0;"> <div class="form-group subgroup-count-field">
<label for="manage-subgroup-name">Название подгруппы</label> <label for="manage-subgroup-count">Количество подгрупп</label>
<input type="text" id="manage-subgroup-name" placeholder="Подгруппа 1" required> <select id="manage-subgroup-count">
<option value="0">Без разделения на подгруппы</option>
<option value="2">Две подгруппы</option>
<option value="3">Три подгруппы</option>
</select>
</div> </div>
<div class="form-group" style="margin-bottom: 0;"> <div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
<label for="manage-subgroup-capacity">Численность (чел.)</label> <div class="subgroup-actions">
<input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required> <button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Применить</button>
</div>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0;">
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Создать</button>
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button> <button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
</div> </div>
</div> </div>

View File

@@ -91,6 +91,72 @@
</form> </form>
</div> </div>
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-conflict-card">
<button type="button" class="modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Конфликт слота</span>
<h2>Освободите место для нового правила</h2>
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
<div class="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden></div>
</div>
<div class="schedule-conflict-rules">
<section class="schedule-conflict-rule-box">
<span>Новое правило</span>
<strong id="schedule-conflict-new-rule">-</strong>
<small id="schedule-conflict-new-rule-meta">-</small>
</section>
<section class="schedule-conflict-rule-box schedule-conflict-rule-box-active">
<span>Нужно изменить</span>
<strong id="schedule-conflict-existing-rule">-</strong>
<small id="schedule-conflict-existing-rule-meta">-</small>
</section>
</div>
<form id="schedule-conflict-form" class="schedule-conflict-form" novalidate>
<div class="schedule-conflict-slots" id="schedule-conflict-slots"></div>
<div class="form-alert" id="schedule-conflict-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-conflict-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-conflict-submit">Сохранить и повторить</button>
</div>
</form>
</div>
</div>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden>
<button type="button" data-action="edit-rule">Открыть правило</button>
<button type="button" data-action="move-slot">Изменить день и пару</button>
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button>
</div>
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-move-slot-card">
<button type="button" class="modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Перенос слота</span>
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>
<p id="schedule-move-slot-summary">Будут изменены только день недели и базовая пара выбранного слота.</p>
</div>
<form id="schedule-move-slot-form" class="schedule-move-slot-form" novalidate>
<div class="form-row">
<div class="form-group">
<label for="schedule-move-slot-day">День</label>
<select id="schedule-move-slot-day" required></select>
</div>
<div class="form-group">
<label for="schedule-move-slot-time">Пара</label>
<select id="schedule-move-slot-time" required></select>
</div>
</div>
<div class="form-alert" id="schedule-move-slot-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-move-slot-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-move-slot-submit">Сохранить перенос</button>
</div>
</form>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Правила динамического расписания</h2> <h2>Правила динамического расписания</h2>

View File

@@ -0,0 +1,28 @@
<div class="card">
<div class="card-header-row">
<h2>Заявки на преподавателей</h2>
<div class="teacher-request-toolbar">
<span class="muted" id="teacher-requests-count">0 заявок</span>
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
</div>
</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>

View File

@@ -1,13 +1,13 @@
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== --> <!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
<div class="tab-container"> <div class="tab-container">
<div class="tabs-header-buttons" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;"> <div class="university-structure-tabs" role="tablist" aria-label="Разделы структуры вуза">
<button class="btn-tab active" id="btn-tab-departments" style="background: none; border: none; border-bottom: 2px solid var(--primary); color: var(--text-main); font-weight: 600; padding: 0.5rem 1rem; cursor: pointer;">Кафедры</button> <button type="button" class="university-structure-tab active" id="btn-tab-departments" role="tab" aria-selected="true" aria-controls="tab-content-departments">Кафедры</button>
<button class="btn-tab" id="btn-tab-specialties" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Специальности</button> <button type="button" class="university-structure-tab" id="btn-tab-specialties" role="tab" aria-selected="false" aria-controls="tab-content-specialties">Специальности</button>
<button class="btn-tab" id="btn-tab-profiles" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Профили</button> <button type="button" class="university-structure-tab" id="btn-tab-profiles" role="tab" aria-selected="false" aria-controls="tab-content-profiles">Профили</button>
</div> </div>
<!-- Раздел Кафедр --> <!-- Раздел Кафедр -->
<div id="tab-content-departments" class="tab-content-section"> <div id="tab-content-departments" class="university-structure-tab-panel active" role="tabpanel" aria-labelledby="btn-tab-departments">
<div class="card create-card"> <div class="card create-card">
<h2>Создание кафедры</h2> <h2>Создание кафедры</h2>
<form id="create-department-form"> <form id="create-department-form">
@@ -51,7 +51,7 @@
</div> </div>
<!-- Раздел Специальностей --> <!-- Раздел Специальностей -->
<div id="tab-content-specialties" class="tab-content-section" style="display: none;"> <div id="tab-content-specialties" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-specialties" hidden>
<div class="card create-card"> <div class="card create-card">
<h2>Создание специальности</h2> <h2>Создание специальности</h2>
<form id="create-specialty-form"> <form id="create-specialty-form">
@@ -92,9 +92,10 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<!-- Раздел Профилей --> <!-- Раздел Профилей -->
<div id="tab-content-profiles" class="tab-content-section" style="display: none;"> <div id="tab-content-profiles" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-profiles" hidden>
<div class="card create-card"> <div class="card create-card">
<h2 id="main-profile-form-title">Создание профиля</h2> <h2 id="main-profile-form-title">Создание профиля</h2>
<form id="create-profile-tab-form"> <form id="create-profile-tab-form">

View File

@@ -9,7 +9,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-password">Пароль</label> <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>
<div class="form-group"> <div class="form-group">
<label for="new-role">Роль</label> <label for="new-role">Роль</label>