Compare commits
11 Commits
f3273f6972
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db0218f4ca | |||
| 9e6e0038c6 | |||
| 6a03931190 | |||
|
|
3d50b6b006 | ||
|
|
53298d9d07 | ||
| 776728cf83 | |||
|
|
08b8607f05 | ||
| 6d9f6b7a42 | |||
| 4e1985c28e | |||
|
|
a966648a7e | ||
| da7c90fa6a |
@@ -166,6 +166,7 @@ public class AcademicCalendarController {
|
||||
}
|
||||
try {
|
||||
replaceCalendarSubjects(calendar, rows == null ? List.of() : rows);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
|
||||
.map(this::toCalendarSubjectDto)
|
||||
.toList());
|
||||
@@ -284,6 +285,7 @@ public class AcademicCalendarController {
|
||||
}
|
||||
|
||||
calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId());
|
||||
calendarSubjectRepository.flush();
|
||||
List<AcademicCalendarSubject> entities = rows.stream()
|
||||
.map(row -> {
|
||||
AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject();
|
||||
|
||||
@@ -6,11 +6,13 @@ import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@@ -19,6 +21,12 @@ import java.util.*;
|
||||
public class ScheduleRuleAdminController {
|
||||
|
||||
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 SubjectRepository subjectRepository;
|
||||
@@ -81,6 +89,10 @@ public class ScheduleRuleAdminController {
|
||||
try {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
applyRule(rule, request);
|
||||
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
|
||||
if (conflict != null) {
|
||||
return conflictResponse(conflict);
|
||||
}
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
@@ -103,6 +115,12 @@ public class ScheduleRuleAdminController {
|
||||
}
|
||||
|
||||
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);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
@@ -181,12 +199,250 @@ public class ScheduleRuleAdminController {
|
||||
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) {
|
||||
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
|
||||
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
|
||||
}
|
||||
if (slotDto.parity() == null) {
|
||||
throw new IllegalArgumentException("Чётность недели обязательна");
|
||||
if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
|
||||
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
|
||||
}
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||
@@ -402,4 +658,7 @@ public class ScheduleRuleAdminController {
|
||||
.thenComparing(Subgroup::getName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ public class SubgroupController {
|
||||
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
|
||||
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.setStudentGroup(group);
|
||||
@@ -95,6 +99,10 @@ public class SubgroupController {
|
||||
if (duplicateName) {
|
||||
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.setStudentCapacity(request.studentCapacity());
|
||||
@@ -113,6 +121,10 @@ public class SubgroupController {
|
||||
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup);
|
||||
if (deleteValidationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
|
||||
}
|
||||
try {
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
@@ -127,8 +139,39 @@ public class SubgroupController {
|
||||
if (request == null || request.name() == null || request.name().isBlank()) {
|
||||
return "Название подгруппы обязательно";
|
||||
}
|
||||
if (request.studentCapacity() != null && request.studentCapacity() < 0) {
|
||||
return "Численность подгруппы не может быть отрицательной";
|
||||
if (request.studentCapacity() == null) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
""")
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
25
docs/API.md
25
docs/API.md
@@ -435,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`
|
||||
|
||||
@@ -484,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`
|
||||
|
||||
Справочник типов занятий для конструктора правил расписания.
|
||||
@@ -758,6 +777,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
|
||||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
||||
|
||||
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
|
||||
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
|
||||
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/subgroups` | Список всех подгрупп |
|
||||
|
||||
@@ -60,7 +60,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
|
||||
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
||||
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
|
||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
|
||||
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||
@@ -180,13 +180,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
|
||||
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
|
||||
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
|
||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
|
||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
||||
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
|
||||
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
||||
- **Конфликты слотов:** при создании и обновлении правил проверяются активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict` с ранее созданным `conflictRule`, чтобы frontend мог предложить перенос этого правила.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
|
||||
@@ -470,7 +470,7 @@ erDiagram
|
||||
| `name` | VARCHAR(100) | Название подгруппы |
|
||||
| `student_capacity` | INT | Количество студентов |
|
||||
|
||||
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий.
|
||||
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
|
||||
|
||||
#### `subjects` — Дисциплины
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -777,11 +777,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
<<<<<<< HEAD
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
=======
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
>>>>>>> 43d72c52356686ada2a54d73eccfdf491579fa0c
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -37,35 +37,31 @@ frontend/
|
||||
│ │ ├── utils.js # Утилиты
|
||||
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
|
||||
│ │ └── views/ # Модули представлений
|
||||
│ │ ├── users.js # Управление пользователями
|
||||
│ │ ├── groups.js # Управление группами
|
||||
│ │ ├── classrooms.js # Управление аудиториями
|
||||
│ │ ├── subjects.js # Управление дисциплинами
|
||||
│ │ ├── equipments.js # Управление оборудованием
|
||||
│ │ ├── edu-forms.js # Формы обучения
|
||||
│ │ ├── profiles.js # Профили обучения специальностей
|
||||
│ │ ├── dashboard.js # Дашборд
|
||||
│ │ ├── users.js # Управление пользователями
|
||||
│ │ ├── teacher-requests.js # Заявки кафедр на преподавателей
|
||||
│ │ ├── groups.js # Управление группами
|
||||
│ │ ├── classrooms.js # Управление аудиториями
|
||||
│ │ ├── subjects.js # Управление дисциплинами
|
||||
│ │ ├── university-structure.js # Кафедры, специальности и профили обучения
|
||||
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
|
||||
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
|
||||
│ │ ├── schedule.js # Конструктор правил расписания
|
||||
│ │ ├── schedule.js # Конструктор правил расписания
|
||||
│ │ ├── academic-calendar.js # Календарные учебные графики
|
||||
│ │ ├── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
|
||||
│ │ ├── database.js # Управление тенантами
|
||||
│ │ └── departments-data.js # Создание кафедры/специальности
|
||||
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
|
||||
│ ├── views/ # HTML-шаблоны представлений
|
||||
│ │ ├── dashboard.html
|
||||
│ │ ├── users.html
|
||||
│ │ ├── teacher-requests.html
|
||||
│ │ ├── groups.html
|
||||
│ │ ├── classrooms.html
|
||||
│ │ ├── subjects.html
|
||||
│ │ ├── equipments.html
|
||||
│ │ ├── edu-forms.html
|
||||
│ │ ├── profiles.html
|
||||
│ │ ├── university-structure.html
|
||||
│ │ ├── department-workspace.html
|
||||
│ │ ├── schedule-view.html
|
||||
│ │ ├── schedule.html
|
||||
│ │ ├── academic-calendar.html
|
||||
│ │ ├── auditorium-workload.html
|
||||
│ │ ├── database.html
|
||||
│ │ └── departments-data.html
|
||||
│ │ └── auditorium-workload.html
|
||||
│ │
|
||||
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
|
||||
│ ├── index.html # Оболочка с собственной sidebar
|
||||
@@ -129,38 +125,32 @@ frontend/
|
||||
|
||||
| Tab | Описание | API |
|
||||
|-----|----------|-----|
|
||||
| `users` | CRUD пользователей и очередь заявок кафедр на создание преподавателей | `/api/users`, `/api/teacher-requests` |
|
||||
| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
|
||||
| `edu-forms` | Формы обучения | `/api/education-forms` |
|
||||
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
|
||||
| `equipments` | Оборудование | `/api/equipments` |
|
||||
| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
|
||||
| `users` | CRUD пользователей | `/api/users` |
|
||||
| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
|
||||
| `classrooms` | Аудитории | `/api/classrooms` |
|
||||
| `subjects` | Дисциплины | `/api/subjects` |
|
||||
| `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` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/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` |
|
||||
| `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` |
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
<<<<<<< HEAD
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||
- Вкладка `users` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`.
|
||||
=======
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||
>>>>>>> 43d72c52356686ada2a54d73eccfdf491579fa0c
|
||||
- Вкладка `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/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
|
||||
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
||||
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
||||
- Вкладка `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` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
||||
|
||||
|
||||
@@ -385,6 +385,22 @@
|
||||
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,
|
||||
.department-workspace-grid {
|
||||
display: grid;
|
||||
@@ -397,7 +413,63 @@
|
||||
}
|
||||
|
||||
.department-workspace-grid-wide {
|
||||
grid-template-columns: repeat(4, minmax(220px, 1fr));
|
||||
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-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 {
|
||||
@@ -407,6 +479,12 @@
|
||||
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;
|
||||
@@ -427,7 +505,6 @@
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
.department-subject-import-card,
|
||||
.department-teachers-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -565,6 +642,274 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -575,7 +920,7 @@
|
||||
|
||||
.subject-import-row {
|
||||
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;
|
||||
row-gap: 0.75rem;
|
||||
align-items: end;
|
||||
@@ -722,6 +1067,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-import-row .btn {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.subject-import-row .form-group label {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
@@ -731,6 +1080,14 @@
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -740,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 {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
@@ -1152,31 +1560,31 @@
|
||||
|
||||
.schedule-visual-edit-rule {
|
||||
position: absolute;
|
||||
top: 0.26rem;
|
||||
right: 0.26rem;
|
||||
top: 0.18rem;
|
||||
right: 0.2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
width: 1rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border));
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-card));
|
||||
color: var(--accent-hover);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
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:focus-visible {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-card));
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
color: var(--accent-hover);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -1216,7 +1624,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.academic-calendar-tabs {
|
||||
.academic-calendar-tabs,
|
||||
.university-structure-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
@@ -1224,7 +1633,8 @@
|
||||
border-bottom: 1px solid var(--bg-card-border);
|
||||
}
|
||||
|
||||
.academic-calendar-tab {
|
||||
.academic-calendar-tab,
|
||||
.university-structure-tab {
|
||||
min-height: 40px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
@@ -1239,24 +1649,29 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
background: var(--bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.academic-calendar-tab.active {
|
||||
.academic-calendar-tab.active,
|
||||
.university-structure-tab.active {
|
||||
color: var(--accent-hover);
|
||||
background: var(--bg-card);
|
||||
border-color: var(--bg-card-border);
|
||||
}
|
||||
|
||||
.academic-calendar-tab-panel {
|
||||
.academic-calendar-tab-panel,
|
||||
.university-structure-tab-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.academic-calendar-tab-panel[hidden] {
|
||||
.academic-calendar-tab-panel[hidden],
|
||||
.university-structure-tab-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1869,7 +2284,7 @@
|
||||
@media (max-width: 1350px) {
|
||||
.schedule-view-filter-grid,
|
||||
.schedule-view-metrics,
|
||||
.department-workspace-grid {
|
||||
.department-workspace-grid:not(.department-workspace-grid-wide) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1952,7 +2367,8 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.academic-calendar-tab {
|
||||
.academic-calendar-tab,
|
||||
.university-structure-tab {
|
||||
flex: 1 1 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
@@ -2255,6 +2671,86 @@ input[type="number"] {
|
||||
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 {
|
||||
@@ -2380,6 +2876,17 @@ input[type="number"] {
|
||||
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 {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
flex: 0 0 auto;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
@@ -112,6 +113,38 @@
|
||||
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 {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--bg-card-border);
|
||||
@@ -247,7 +280,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item span,
|
||||
.sidebar.collapsed .nav-item span:not(.nav-badge),
|
||||
.sidebar.collapsed .btn-settings span {
|
||||
position: absolute;
|
||||
left: calc(100% + 10px);
|
||||
@@ -271,13 +304,13 @@
|
||||
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 {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
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 {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
@@ -299,6 +332,16 @@
|
||||
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 {
|
||||
justify-content: center;
|
||||
padding: 0.65rem 0;
|
||||
@@ -333,9 +376,44 @@
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.topbar-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
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 {
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
|
||||
@@ -73,3 +73,82 @@
|
||||
.modal-close:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,17 @@
|
||||
<span>Дашборд</span>
|
||||
</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">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -221,7 +232,10 @@
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
<div class="topbar-title-group">
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dynamic Content Injected Here -->
|
||||
|
||||
@@ -63,7 +63,10 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 { isAuthenticatedAsAny, logout } from './api.js';
|
||||
import { api, isAuthenticatedAsAny, logout } from './api.js';
|
||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||
|
||||
@@ -38,6 +38,14 @@ const ROUTES = {
|
||||
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: {
|
||||
title: 'Управление группами',
|
||||
file: '/admin/views/groups.html',
|
||||
@@ -115,7 +123,7 @@ const ROUTES = {
|
||||
const ROLE_NAVIGATION = {
|
||||
ADMIN: {
|
||||
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: {
|
||||
defaultTab: 'dashboard',
|
||||
@@ -143,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
|
||||
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
||||
const btnLogout = document.getElementById('btn-logout');
|
||||
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 allowedTabs = new Set(roleNavigation.tabs);
|
||||
|
||||
@@ -150,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
|
||||
applyRippleEffect();
|
||||
closeAllDropdownsOnOutsideClick();
|
||||
configureRoleInterface();
|
||||
refreshTeacherRequestBadge();
|
||||
|
||||
// Init sidebar state from localStorage on load
|
||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
@@ -207,6 +218,10 @@ btnLogout.addEventListener('click', async () => {
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
teacherRequestsTitleBadge?.addEventListener('click', () => {
|
||||
switchTab('teacher-requests');
|
||||
});
|
||||
|
||||
// Navigation
|
||||
navItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
@@ -271,6 +286,42 @@ function configureRoleInterface() {
|
||||
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('#', '');
|
||||
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ function renderEquipmentCheckboxes(equipments, containerId, selectTextId, select
|
||||
}
|
||||
|
||||
container.innerHTML = equipments.map(eq => `
|
||||
<label class="checkbox-container">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>${escapeHtml(eq.name)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
@@ -10,6 +10,8 @@ async function fetchEducationForms() {
|
||||
}
|
||||
}
|
||||
|
||||
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
|
||||
|
||||
export async function initGroups() {
|
||||
const groupsTbody = document.getElementById('groups-tbody');
|
||||
const createGroupForm = document.getElementById('create-group-form');
|
||||
@@ -17,7 +19,10 @@ export async function initGroups() {
|
||||
const newGroupDepartmentSelect = document.getElementById('new-group-department');
|
||||
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
|
||||
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');
|
||||
@@ -36,8 +41,8 @@ export async function initGroups() {
|
||||
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
|
||||
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
|
||||
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
|
||||
const manageSubgroupNameInput = document.getElementById('manage-subgroup-name');
|
||||
const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity');
|
||||
const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
|
||||
const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
|
||||
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
|
||||
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
|
||||
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
|
||||
@@ -61,12 +66,14 @@ export async function initGroups() {
|
||||
let academicYears = [];
|
||||
let calendars = [];
|
||||
let currentAssignments = [];
|
||||
let activeSubgroupGroup = null;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
|
||||
function bindEvents() {
|
||||
filterEfSelect.addEventListener('change', applyGroupFilter);
|
||||
initFilterEfDropdown();
|
||||
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
|
||||
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
|
||||
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
|
||||
|
||||
@@ -75,6 +82,7 @@ export async function initGroups() {
|
||||
|
||||
// Модалка подгрупп
|
||||
manageSubgroupForm.addEventListener('submit', saveSubgroup);
|
||||
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
|
||||
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
|
||||
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() {
|
||||
try {
|
||||
[educationForms, departments, specialties, academicYears, calendars] = await Promise.all([
|
||||
@@ -148,13 +187,63 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function applyGroupFilter() {
|
||||
const filterId = filterEfSelect.value;
|
||||
const filtered = filterId
|
||||
? allGroups.filter(group => group.educationFormId == filterId)
|
||||
const selectedIds = selectedFilterEducationFormIds();
|
||||
const filtered = selectedIds.length
|
||||
? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
|
||||
: allGroups;
|
||||
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) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
@@ -188,11 +277,30 @@ export async function initGroups() {
|
||||
function populateEfSelects(forms) {
|
||||
populateEfSelect(newGroupEfSelect, forms);
|
||||
populateEfSelect(editGroupEfSelect, forms);
|
||||
const currentFilter = filterEfSelect.value;
|
||||
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
|
||||
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||||
if (currentFilter) filterEfSelect.value = currentFilter;
|
||||
syncSelects(filterEfSelect);
|
||||
renderFilterEfCheckboxes(forms);
|
||||
}
|
||||
|
||||
function renderFilterEfCheckboxes(forms) {
|
||||
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) {
|
||||
@@ -411,6 +519,7 @@ export async function initGroups() {
|
||||
|
||||
// --- Управление подгруппами ---
|
||||
function openSubgroupsModal(group) {
|
||||
activeSubgroupGroup = group;
|
||||
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||||
manageSubgroupGroupIdInput.value = group.id;
|
||||
resetSubgroupForm();
|
||||
@@ -424,13 +533,19 @@ export async function initGroups() {
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
const canDeleteSingleSubgroup = list.length === 1;
|
||||
const deleteTitle = canDeleteSingleSubgroup
|
||||
? 'Удалить подгруппу'
|
||||
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
|
||||
manageSubgroupsTbody.innerHTML = list.map(sub => `
|
||||
<tr>
|
||||
<td>${escapeHtml(sub.name)}</td>
|
||||
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
|
||||
<td>
|
||||
<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>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -438,46 +553,157 @@ export async function initGroups() {
|
||||
|
||||
function resetSubgroupForm() {
|
||||
manageSubgroupIdInput.value = '';
|
||||
manageSubgroupNameInput.value = '';
|
||||
manageSubgroupCapacityInput.value = '';
|
||||
subgroupFormTitle.textContent = 'Создание подгруппы';
|
||||
btnSaveSubgroup.textContent = 'Создать';
|
||||
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
|
||||
const count = [2, 3].includes(current.length) ? current.length : 0;
|
||||
manageSubgroupCountSelect.value = String(count);
|
||||
syncSelects(manageSubgroupCountSelect);
|
||||
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
|
||||
subgroupFormTitle.textContent = 'Настройка подгрупп';
|
||||
btnSaveSubgroup.textContent = 'Применить';
|
||||
btnCancelSubgroupEdit.style.display = 'none';
|
||||
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) {
|
||||
event.preventDefault();
|
||||
hideAlert('manage-subgroup-alert');
|
||||
const groupId = manageSubgroupGroupIdInput.value;
|
||||
const subgroupId = manageSubgroupIdInput.value;
|
||||
const name = manageSubgroupNameInput.value.trim();
|
||||
const capacity = manageSubgroupCapacityInput.value;
|
||||
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
|
||||
const count = Number(manageSubgroupCountSelect.value);
|
||||
const existingSubgroups = currentGroupSubgroups(groupId);
|
||||
|
||||
if (!name || !capacity) {
|
||||
showAlert('manage-subgroup-alert', 'Заполните все поля', 'error');
|
||||
if (!group) {
|
||||
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
studentCapacity: Number(capacity)
|
||||
};
|
||||
if (count === 0) {
|
||||
await applyNoSubgroups(groupId, existingSubgroups);
|
||||
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 {
|
||||
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`;
|
||||
const saved = subgroupId
|
||||
? await api.put(endpoint, payload)
|
||||
: await api.post(endpoint, payload);
|
||||
showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
|
||||
resetSubgroupForm();
|
||||
await syncSubgroups(groupId, existingSubgroups, capacities);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
|
||||
await loadSubgroups();
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (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) {
|
||||
const editButton = event.target.closest('.btn-edit-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);
|
||||
if (!subgroup) return;
|
||||
|
||||
manageSubgroupIdInput.value = subgroup.id;
|
||||
manageSubgroupNameInput.value = subgroup.name || '';
|
||||
manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? '';
|
||||
subgroupFormTitle.textContent = 'Редактирование подгруппы';
|
||||
btnSaveSubgroup.textContent = 'Сохранить';
|
||||
btnCancelSubgroupEdit.style.display = 'inline-block';
|
||||
resetSubgroupForm();
|
||||
const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
|
||||
const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
|
||||
input?.focus();
|
||||
hideAlert('manage-subgroup-alert');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (deleteButton.disabled) {
|
||||
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Удалить подгруппу?')) return;
|
||||
try {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||||
|
||||
@@ -87,6 +87,28 @@ export async function initSchedule() {
|
||||
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
|
||||
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
|
||||
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 subjects = [];
|
||||
@@ -104,6 +126,9 @@ export async function initSchedule() {
|
||||
let visualGroupFilterTouched = false;
|
||||
let visualPeriodTouched = false;
|
||||
let syncingVisualPeriod = false;
|
||||
let pendingConflictResolution = null;
|
||||
let visualContextTarget = null;
|
||||
let pendingSlotMove = null;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -127,6 +152,19 @@ export async function initSchedule() {
|
||||
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||||
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', () => {
|
||||
const formSemesterId = ruleSemesterSelect.value;
|
||||
if (formSemesterId) {
|
||||
@@ -148,7 +186,25 @@ export async function initSchedule() {
|
||||
renderVisualSchedule();
|
||||
});
|
||||
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) => {
|
||||
@@ -401,6 +457,10 @@ export async function initSchedule() {
|
||||
const lesson = {
|
||||
id: `${rule.id}:${slotKey}:${groupId}`,
|
||||
ruleId: rule.id,
|
||||
slotId: slot.id || '',
|
||||
slotIndex: index,
|
||||
dayOfWeek: slot.dayOfWeek,
|
||||
timeSlotId: slot.timeSlotId,
|
||||
parity: slot.parity || 'BOTH',
|
||||
subjectName: rule.subjectName || 'Без дисциплины',
|
||||
teacherName: slot.teacherName || '',
|
||||
@@ -498,8 +558,10 @@ export async function initSchedule() {
|
||||
<button type="button"
|
||||
class="schedule-visual-edit-rule"
|
||||
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
|
||||
title="Изменить правило"
|
||||
aria-label="${escapeHtml(`Изменить правило: ${lesson.subjectName}`)}">⋮</button>
|
||||
data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
|
||||
data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
|
||||
title="Действия со слотом"
|
||||
aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">⋮</button>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
@@ -580,14 +642,197 @@ export async function initSchedule() {
|
||||
function handleVisualStageClick(event) {
|
||||
const editButton = event.target.closest('.schedule-visual-edit-rule');
|
||||
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) {
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
return;
|
||||
}
|
||||
fillRuleForm(rule);
|
||||
closeVisualDrawer();
|
||||
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
|
||||
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);
|
||||
closeVisualDrawer();
|
||||
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) {
|
||||
@@ -770,20 +1015,302 @@ export async function initSchedule() {
|
||||
async function saveRule(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('schedule-rule-alert');
|
||||
let payload = null;
|
||||
let id = '';
|
||||
try {
|
||||
const payload = buildRulePayload();
|
||||
const id = ruleIdInput.value;
|
||||
const saved = id
|
||||
? await api.put('/api/admin/schedule-rules/' + id, payload)
|
||||
: await api.post('/api/admin/schedule-rules', payload);
|
||||
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
|
||||
resetRuleForm();
|
||||
await loadRules();
|
||||
payload = buildRulePayload();
|
||||
id = ruleIdInput.value;
|
||||
const saved = await saveRulePayload(id, payload);
|
||||
await finishSuccessfulRuleSave(saved);
|
||||
} catch (error) {
|
||||
if (payload && isScheduleRuleConflictError(error)) {
|
||||
openConflictModal(error, payload, id);
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
|
||||
136
frontend/admin/js/views/teacher-requests.js
Normal file
136
frontend/admin/js/views/teacher-requests.js
Normal 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>`;
|
||||
}
|
||||
}
|
||||
@@ -53,41 +53,26 @@ export async function initUniversityStructure() {
|
||||
let allProfiles = [];
|
||||
|
||||
// --- Логика переключения табов ---
|
||||
function deactivateTabs() {
|
||||
[tabDepartments, tabSpecialties, tabProfiles].forEach(tab => {
|
||||
if (tab) {
|
||||
tab.classList.remove('active');
|
||||
tab.style.borderBottomColor = 'transparent';
|
||||
tab.style.color = 'var(--text-secondary)';
|
||||
const structureTabs = [
|
||||
{ button: tabDepartments, panel: contentDepartments },
|
||||
{ button: tabSpecialties, panel: contentSpecialties },
|
||||
{ button: tabProfiles, panel: contentProfiles },
|
||||
];
|
||||
|
||||
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', () => {
|
||||
deactivateTabs();
|
||||
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';
|
||||
structureTabs.forEach(({ button }) => {
|
||||
button?.addEventListener('click', () => activateStructureTab(button));
|
||||
});
|
||||
|
||||
// --- Загрузка данных ---
|
||||
|
||||
@@ -21,9 +21,6 @@ const ROLE_BADGE = {
|
||||
export async function initUsers() {
|
||||
const usersTbody = document.getElementById('users-tbody');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const teacherRequestsTbody = document.getElementById('teacher-requests-tbody');
|
||||
const teacherRequestsRefresh = document.getElementById('teacher-requests-refresh');
|
||||
let departments = [];
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
@@ -36,53 +33,6 @@ export async function initUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
departments = await api.get('/api/departments');
|
||||
}
|
||||
|
||||
async function loadTeacherRequests() {
|
||||
if (!teacherRequestsTbody) return;
|
||||
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
hideAlert('teacher-requests-alert');
|
||||
try {
|
||||
const requests = await api.get('/api/teacher-requests?status=PENDING');
|
||||
renderTeacherRequests(Array.isArray(requests) ? requests : []);
|
||||
} catch (error) {
|
||||
teacherRequestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTeacherRequests(requests) {
|
||||
if (!requests.length) {
|
||||
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
|
||||
return;
|
||||
}
|
||||
teacherRequestsTbody.innerHTML = requests.map(request => `
|
||||
<tr data-request-id="${escapeHtml(request.id)}">
|
||||
<td>
|
||||
<select class="teacher-request-department" data-native-select="true">
|
||||
${departments.map(department => `
|
||||
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
|
||||
${escapeHtml(department.departmentName || department.name || department.id)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
|
||||
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
|
||||
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
|
||||
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
|
||||
<td>${escapeHtml(request.comment || '-')}</td>
|
||||
<td>
|
||||
<div class="teacher-request-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
if (!users || !users.length) {
|
||||
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
|
||||
@@ -143,54 +93,6 @@ export async function initUsers() {
|
||||
}
|
||||
});
|
||||
|
||||
teacherRequestsRefresh?.addEventListener('click', loadTeacherRequests);
|
||||
|
||||
teacherRequestsTbody?.addEventListener('click', async (event) => {
|
||||
const approveButton = event.target.closest('.teacher-request-approve');
|
||||
const rejectButton = event.target.closest('.teacher-request-reject');
|
||||
if (!approveButton && !rejectButton) return;
|
||||
|
||||
const row = event.target.closest('tr[data-request-id]');
|
||||
const requestId = row?.dataset.requestId;
|
||||
if (!requestId) return;
|
||||
|
||||
if (approveButton) {
|
||||
const payload = {
|
||||
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
|
||||
username: row.querySelector('.teacher-request-username')?.value.trim(),
|
||||
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
|
||||
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
|
||||
password: row.querySelector('.teacher-request-password')?.value
|
||||
};
|
||||
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
|
||||
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
|
||||
return;
|
||||
}
|
||||
if (payload.password.length < 8) {
|
||||
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
|
||||
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
|
||||
await Promise.all([loadTeacherRequests(), loadUsers()]);
|
||||
} catch (error) {
|
||||
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewComment = prompt('Причина отклонения');
|
||||
if (reviewComment === null) return;
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
|
||||
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
|
||||
await loadTeacherRequests();
|
||||
} catch (error) {
|
||||
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
usersTbody.addEventListener('click', async (e) => {
|
||||
const deleteBtn = e.target.closest('.btn-archive-user');
|
||||
if (!deleteBtn) return;
|
||||
@@ -216,6 +118,5 @@ export async function initUsers() {
|
||||
}
|
||||
});
|
||||
|
||||
await loadDepartments();
|
||||
await Promise.all([loadUsers(), loadTeacherRequests()]);
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="card">
|
||||
<div class="card department-workspace-control-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Рабочая область кафедры</h2>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-view-filter-grid">
|
||||
<div class="department-workspace-filter-grid">
|
||||
<div class="form-group" id="department-workspace-department-field">
|
||||
<label for="department-workspace-department">Кафедра</label>
|
||||
<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>
|
||||
|
||||
<div class="department-workspace-grid department-workspace-grid-wide">
|
||||
<div class="card create-card">
|
||||
<div class="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
|
||||
<div class="card create-card department-subject-import-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Загрузка дисциплин</h2>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<div class="card create-card department-teacher-assign-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Добавить преподавателя</h2>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<div class="card create-card department-teacher-request-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявка на преподавателя</h2>
|
||||
</div>
|
||||
@@ -88,7 +88,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card department-teachers-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Преподаватели и нагрузка</h2>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@
|
||||
</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>
|
||||
@@ -120,7 +120,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card department-workspace-table-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Дисциплины кафедры</h2>
|
||||
<span class="muted" id="department-subject-count">0 дисциплин</span>
|
||||
@@ -142,7 +142,7 @@
|
||||
</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">
|
||||
<h2 id="department-comments-title">Комментарии</h2>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button>
|
||||
|
||||
@@ -49,10 +49,26 @@
|
||||
<div class="card-header-row">
|
||||
<h2>Все группы</h2>
|
||||
<div class="filter-row">
|
||||
<label for="filter-ef">Фильтр:</label>
|
||||
<select id="filter-ef">
|
||||
<option value="">Все формы</option>
|
||||
</select>
|
||||
<label for="filter-ef-box">Фильтр:</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
|
||||
<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 class="table-wrap">
|
||||
@@ -82,12 +98,12 @@
|
||||
|
||||
<!-- Модальное окно редактирования группы -->
|
||||
<div class="modal-overlay" id="modal-edit-group">
|
||||
<div class="modal-content card">
|
||||
<div class="modal-content card edit-group-modal">
|
||||
<h2>Редактировать группу</h2>
|
||||
<button class="modal-close" id="modal-edit-group-close">×</button>
|
||||
<form id="edit-group-form">
|
||||
<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">
|
||||
<label for="edit-group-name">Название группы</label>
|
||||
<input type="text" id="edit-group-name" required>
|
||||
@@ -103,7 +119,7 @@
|
||||
</select>
|
||||
</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">
|
||||
<label for="edit-group-department">Кафедра</label>
|
||||
<select id="edit-group-department">
|
||||
@@ -126,8 +142,8 @@
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button>
|
||||
<div class="form-group edit-group-submit">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-group-alert" role="alert"></div>
|
||||
@@ -152,17 +168,18 @@
|
||||
<form id="manage-subgroup-form">
|
||||
<input type="hidden" id="manage-subgroup-id">
|
||||
<input type="hidden" id="manage-subgroup-group-id">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-subgroup-name">Название подгруппы</label>
|
||||
<input type="text" id="manage-subgroup-name" placeholder="Подгруппа 1" required>
|
||||
<div class="subgroup-config-grid">
|
||||
<div class="form-group subgroup-count-field">
|
||||
<label for="manage-subgroup-count">Количество подгрупп</label>
|
||||
<select id="manage-subgroup-count">
|
||||
<option value="0">Без разделения на подгруппы</option>
|
||||
<option value="2">Две подгруппы</option>
|
||||
<option value="3">Три подгруппы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-subgroup-capacity">Численность (чел.)</label>
|
||||
<input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required>
|
||||
</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>
|
||||
<div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
|
||||
<div class="subgroup-actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,72 @@
|
||||
</form>
|
||||
</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="Закрыть окно">×</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="Закрыть окно">×</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-header-row">
|
||||
<h2>Правила динамического расписания</h2>
|
||||
|
||||
28
frontend/admin/views/teacher-requests.html
Normal file
28
frontend/admin/views/teacher-requests.html
Normal 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>
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
|
||||
<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;">
|
||||
<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 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 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>
|
||||
<div class="university-structure-tabs" role="tablist" aria-label="Разделы структуры вуза">
|
||||
<button type="button" class="university-structure-tab active" id="btn-tab-departments" role="tab" aria-selected="true" aria-controls="tab-content-departments">Кафедры</button>
|
||||
<button type="button" class="university-structure-tab" id="btn-tab-specialties" role="tab" aria-selected="false" aria-controls="tab-content-specialties">Специальности</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 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">
|
||||
<h2>Создание кафедры</h2>
|
||||
<form id="create-department-form">
|
||||
@@ -51,7 +51,7 @@
|
||||
</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">
|
||||
<h2>Создание специальности</h2>
|
||||
<form id="create-specialty-form">
|
||||
@@ -90,11 +90,12 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<h2 id="main-profile-form-title">Создание профиля</h2>
|
||||
<form id="create-profile-tab-form">
|
||||
|
||||
@@ -40,32 +40,6 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявки на преподавателей</h2>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Кафедра</th>
|
||||
<th>Логин</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Пароль</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="teacher-requests-tbody">
|
||||
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Все пользователи</h2>
|
||||
<div class="table-wrap">
|
||||
|
||||
Reference in New Issue
Block a user