Добавил возможность решения конкфликтов при создании правил
This commit is contained in:
@@ -12,6 +12,7 @@ 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
|
||||
@@ -20,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;
|
||||
@@ -111,14 +118,16 @@ public class ScheduleRuleAdminController {
|
||||
}
|
||||
|
||||
try {
|
||||
applyRule(rule, request);
|
||||
ScheduleRule conflictRule = findConflictingRule(request, rule.getSlots(), rule.getId());
|
||||
ScheduleRule candidate = new ScheduleRule();
|
||||
applyRule(candidate, request);
|
||||
ScheduleRule conflictRule = findConflictingRule(request, candidate.getSlots(), rule.getId());
|
||||
if (conflictRule != null) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
|
||||
"message", "Невозможно сохранить правило: слот занят",
|
||||
"conflictRule", toDto(conflictRule)
|
||||
));
|
||||
}
|
||||
applyRule(rule, request);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
@@ -206,9 +215,13 @@ public class ScheduleRuleAdminController {
|
||||
}
|
||||
|
||||
private boolean hasSlotConflict(Collection<ScheduleRuleSlot> newSlots, Collection<ScheduleRuleSlot> existingSlots) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
|
||||
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
|
||||
for (ScheduleRuleSlot newSlot : newSlots) {
|
||||
for (ScheduleRuleSlot existingSlot : existingSlots) {
|
||||
if (sameTimeSlot(newSlot, existingSlot) && sameTeacherOrClassroom(newSlot, existingSlot)) {
|
||||
if (sameTimeSlot(newSlot, existingSlot)
|
||||
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))
|
||||
&& sameTeacherOrClassroomOrAudience(newSlot, existingSlot)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -218,21 +231,182 @@ public class ScheduleRuleAdminController {
|
||||
|
||||
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
|
||||
&& Objects.equals(first.getParity(), second.getParity())
|
||||
&& parityOverlaps(first.getParity(), second.getParity())
|
||||
&& Objects.equals(first.getTimeSlot().getId(), second.getTimeSlot().getId());
|
||||
}
|
||||
|
||||
private boolean sameTeacherOrClassroom(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
|
||||
if (Objects.equals(first, second)) {
|
||||
return true;
|
||||
}
|
||||
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
|
||||
}
|
||||
|
||||
private boolean sameTeacherOrClassroomOrAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
return Objects.equals(first.getTeacher().getId(), second.getTeacher().getId())
|
||||
|| Objects.equals(first.getClassroom().getId(), second.getClassroom().getId());
|
||||
|| Objects.equals(first.getClassroom().getId(), second.getClassroom().getId())
|
||||
|| sameAudience(first, second);
|
||||
}
|
||||
|
||||
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("Временной слот не найден"));
|
||||
|
||||
15
docs/API.md
15
docs/API.md
@@ -484,6 +484,21 @@ CRUD доступен по:
|
||||
|
||||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
|
||||
|
||||
`parity` принимает только `BOTH`, `ODD` или `EVEN`. При создании и обновлении правила backend проверяет занятость в рамках семестра: конфликтом считается пересечение дня, базового временного слота, чётности и активных недель слотов, если совпадает преподаватель, аудитория или учебная группа. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. При конфликте API возвращает `409 Conflict`:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Невозможно сохранить правило: слот занят",
|
||||
"conflictRule": {
|
||||
"id": 12,
|
||||
"subjectName": "Математический анализ",
|
||||
"semesterId": 1,
|
||||
"groupNames": ["ИБ-101"],
|
||||
"slots": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/lesson-types`
|
||||
|
||||
Справочник типов занятий для конструктора правил расписания.
|
||||
|
||||
@@ -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 мог предложить перенос этого правила.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
|
||||
@@ -147,8 +147,9 @@ frontend/
|
||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
||||
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
|
||||
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
||||
|
||||
@@ -642,6 +642,185 @@
|
||||
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
||||
}
|
||||
|
||||
.schedule-conflict-modal {
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.schedule-conflict-card {
|
||||
width: min(1440px, calc(100vw - 2rem));
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.schedule-conflict-head {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-right: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.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.45;
|
||||
}
|
||||
|
||||
.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-rules {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.schedule-conflict-rule-box {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
padding: 0.85rem;
|
||||
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.75rem;
|
||||
}
|
||||
|
||||
.schedule-conflict-slots {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.schedule-conflict-slot-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 1.15fr) minmax(130px, 0.7fr) minmax(140px, 0.75fr) minmax(170px, 0.85fr) minmax(240px, 1.15fr) minmax(190px, 1fr);
|
||||
gap: 0.65rem;
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
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-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.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-card {
|
||||
width: min(620px, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.schedule-move-slot-form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.subject-import-rows-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -861,6 +1040,23 @@
|
||||
.subject-import-row .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schedule-conflict-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,6 +87,27 @@ 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 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 +125,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 +151,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 +185,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 +456,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 +557,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 +641,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;
|
||||
}
|
||||
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 +1014,267 @@ 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),
|
||||
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);
|
||||
}
|
||||
renderConflictSlots(conflictRule);
|
||||
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 = '';
|
||||
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 renderConflictSlots(rule) {
|
||||
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);
|
||||
return `
|
||||
<section class="schedule-conflict-slot-row" data-index="${index}">
|
||||
<div class="schedule-conflict-slot-fixed">
|
||||
<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"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -91,6 +91,71 @@
|
||||
</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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user