diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index e00dddd..78882f8 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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 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 newSlots, Collection existingSlots) { + Map> newActiveWeeks = activeWeeksBySlot(newSlots); + Map> 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 firstGroupIds = groupIds(first); + Set secondGroupIds = groupIds(second); + firstGroupIds.retainAll(secondGroupIds); + + for (Long groupId : firstGroupIds) { + if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) { + return true; + } + Set firstSubgroupIds = subgroupIdsForGroup(first, groupId); + Set secondSubgroupIds = subgroupIdsForGroup(second, groupId); + firstSubgroupIds.retainAll(secondSubgroupIds); + if (!firstSubgroupIds.isEmpty()) { + return true; + } + } + return false; + } + + private Set groupIds(ScheduleRuleSlot slot) { + Set 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 subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) { + Set 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> activeWeeksBySlot(Collection slots) { + Map> 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 categorySlots = slots.stream() + .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) + .sorted(this::compareSlotsForGeneration) + .toList(); + if (categorySlots.isEmpty()) { + continue; + } + + Map 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 first, Set second) { + if (first == null || second == null || first.isEmpty() || second.isEmpty()) { + return false; + } + Set 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 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("Временной слот не найден")); diff --git a/docs/API.md b/docs/API.md index 5feedf9..7110420 100644 --- a/docs/API.md +++ b/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` Справочник типов занятий для конструктора правил расписания. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 4ddc311..b9bcb54 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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 мог предложить перенос этого правила. ### Точечные изменения расписания diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 99cd80e..12670bd 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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`; удаление пользователя работает как архивирование. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 1892e41..b401a32 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -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 { diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index 2fbadac..ff34b4c 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -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; diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 79514dd..773db92 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -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() { + data-slot-id="${escapeHtml(String(lesson.slotId || ''))}" + data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}" + title="Действия со слотом" + aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">⋮ `; } @@ -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; } - 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 +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 = '
У конфликтующего правила нет слотов
'; + return; + } + conflictSlotsContainer.innerHTML = slots.map((slot, index) => { + const fixedParts = [ + slot.lessonTypeName, + slot.lessonFormat, + slotSubgroupNames(slot).join(', ') + ].filter(Boolean); + return ` +
+
+ Слот ${index + 1} + ${escapeHtml(fixedParts.join(' · ') || 'Занятие')} + ${escapeHtml(conflictSlotCurrentText(slot))} +
+
+
+
+
+
+
+ `; + }).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); diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 87b62b1..99d8ce5 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -91,6 +91,71 @@ + + + + + +

Правила динамического расписания