From 3d50b6b006e9e23fc7a06656363f9d6d609a0d64 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Wed, 3 Jun 2026 21:22:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BA=D0=BE=D1=81=D1=8F=D0=BA=D0=B8=20=D0=B2=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B0=D1=85=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleRuleAdminController.java | 95 +++++++---- docs/API.md | 6 +- docs/FRONTEND.md | 2 +- frontend/admin/css/components.css | 157 ++++++++++++++---- frontend/admin/css/modals.css | 26 +++ frontend/admin/js/views/schedule.js | 46 ++++- frontend/admin/views/schedule.html | 1 + 7 files changed, 264 insertions(+), 69 deletions(-) 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 78882f8..346a7e4 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -89,12 +89,9 @@ public class ScheduleRuleAdminController { try { ScheduleRule rule = new ScheduleRule(); applyRule(rule, request); - ScheduleRule conflictRule = findConflictingRule(request, rule.getSlots(), null); - if (conflictRule != null) { - return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of( - "message", "Невозможно сохранить правило: слот занят", - "conflictRule", toDto(conflictRule) - )); + ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null); + if (conflict != null) { + return conflictResponse(conflict); } ScheduleRule saved = scheduleRuleRepository.save(rule); scheduleGeneratorService.clearCache(); @@ -120,12 +117,9 @@ public class ScheduleRuleAdminController { try { 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) - )); + ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId()); + if (conflict != null) { + return conflictResponse(conflict); } applyRule(rule, request); ScheduleRule saved = scheduleRuleRepository.save(rule); @@ -205,34 +199,82 @@ public class ScheduleRuleAdminController { rule.getSlots().addAll(slots); } - private ScheduleRule findConflictingRule(ScheduleRuleDto request, Collection newSlots, Long excludeRuleId) { + private ResponseEntity conflictResponse(ScheduleRuleConflict conflict) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of( + "message", "Невозможно сохранить правило: слот занят", + "conflictRule", toDto(conflict.rule()), + "conflictFields", conflict.fields().stream().toList(), + "conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList() + )); + } + + private String conflictFieldLabel(String field) { + return switch (field) { + case "teacher" -> "Преподаватель"; + case "classroom" -> "Аудитория"; + case "group" -> "Группа"; + default -> field; + }; + } + + private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection newSlots, Long excludeRuleId) { + Map> newActiveWeeks = activeWeeksBySlot(newSlots); return scheduleRuleRepository.findAllWithDetails().stream() .filter(rule -> !Objects.equals(rule.getId(), excludeRuleId)) .filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId())) - .filter(rule -> hasSlotConflict(newSlots, rule.getSlots())) - .min(Comparator.comparing(ScheduleRule::getId)) + .map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots())) + .filter(Objects::nonNull) + .min(Comparator.comparing(conflict -> conflict.rule().getId())) .orElse(null); } - private boolean hasSlotConflict(Collection newSlots, Collection existingSlots) { - Map> newActiveWeeks = activeWeeksBySlot(newSlots); + private ScheduleRuleConflict toConflict(ScheduleRule existingRule, + Collection newSlots, + Map> newActiveWeeks, + Collection existingSlots) { Map> existingActiveWeeks = activeWeeksBySlot(existingSlots); + LinkedHashSet fields = new LinkedHashSet<>(); for (ScheduleRuleSlot newSlot : newSlots) { for (ScheduleRuleSlot existingSlot : existingSlots) { if (sameTimeSlot(newSlot, existingSlot) - && activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot)) - && sameTeacherOrClassroomOrAudience(newSlot, existingSlot)) { - return true; + && activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) { + fields.addAll(conflictFields(newSlot, existingSlot)); } } } - return false; + return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields); + } + + private Set conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) { + LinkedHashSet fields = new LinkedHashSet<>(); + if (Objects.equals(teacherId(first), teacherId(second))) { + fields.add("teacher"); + } + if (Objects.equals(classroomId(first), classroomId(second))) { + fields.add("classroom"); + } + if (sameAudience(first, second)) { + fields.add("group"); + } + return fields; } private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) { return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek()) && parityOverlaps(first.getParity(), second.getParity()) - && Objects.equals(first.getTimeSlot().getId(), second.getTimeSlot().getId()); + && Objects.equals(timeSlotId(first), timeSlotId(second)); + } + + private Long teacherId(ScheduleRuleSlot slot) { + return slot.getTeacher() == null ? null : slot.getTeacher().getId(); + } + + private Long classroomId(ScheduleRuleSlot slot) { + return slot.getClassroom() == null ? null : slot.getClassroom().getId(); + } + + private Long timeSlotId(ScheduleRuleSlot slot) { + return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId(); } private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) { @@ -242,12 +284,6 @@ public class ScheduleRuleAdminController { 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()) - || sameAudience(first, second); - } - private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) { Set firstGroupIds = groupIds(first); Set secondGroupIds = groupIds(second); @@ -622,4 +658,7 @@ public class ScheduleRuleAdminController { .thenComparing(Subgroup::getName)) .toList(); } + + private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet fields) { + } } diff --git a/docs/API.md b/docs/API.md index 7110420..aa19075 100644 --- a/docs/API.md +++ b/docs/API.md @@ -495,10 +495,14 @@ CRUD доступен по: "semesterId": 1, "groupNames": ["ИБ-101"], "slots": [] - } + }, + "conflictFields": ["classroom"], + "conflictReasons": ["Аудитория"] } ``` +`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса. + ### `GET /api/lesson-types` Справочник типов занятий для конструктора правил расписания. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 12670bd..b3510af 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -149,7 +149,7 @@ frontend/ - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. - Вкладка `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`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. +- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего 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 b401a32..1e041c3 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -644,18 +644,26 @@ .schedule-conflict-modal { z-index: 1200; + padding: 0.35rem; + overflow-x: hidden; + overflow-y: auto; } -.schedule-conflict-card { - width: min(1440px, calc(100vw - 2rem)); - padding: 1.5rem; +#schedule-conflict-modal .schedule-conflict-card { + width: min(1760px, calc(100vw - 0.7rem)); + max-width: calc(100vw - 0.7rem); + max-height: none; + box-sizing: border-box; + margin: auto; + padding: 1rem; + overflow: visible; } .schedule-conflict-head { display: grid; - gap: 0.45rem; + gap: 0.3rem; margin-right: 2rem; - margin-bottom: 1rem; + margin-bottom: 0.65rem; } .schedule-conflict-head h2 { @@ -669,7 +677,7 @@ .schedule-conflict-head p { margin: 0; color: var(--text-secondary); - line-height: 1.45; + line-height: 1.35; } .schedule-conflict-kicker { @@ -685,18 +693,46 @@ letter-spacing: 0.04em; } +.schedule-conflict-reasons { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-top: 0.15rem; +} + +.schedule-conflict-reasons[hidden] { + display: none; +} + +.schedule-conflict-reasons span { + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 700; +} + +.schedule-conflict-reasons strong { + padding: 0.2rem 0.5rem; + border: 1px solid rgba(245, 158, 11, 0.35); + border-radius: 999px; + background: rgba(245, 158, 11, 0.12); + color: var(--warning); + font-size: 0.76rem; + font-weight: 800; +} + .schedule-conflict-rules { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - margin-bottom: 1rem; + gap: 0.5rem; + margin-bottom: 0.65rem; } .schedule-conflict-rule-box { display: grid; - gap: 0.35rem; + gap: 0.25rem; min-width: 0; - padding: 0.85rem; + padding: 0.65rem; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-input); @@ -731,23 +767,25 @@ .schedule-conflict-form { display: grid; - gap: 0.75rem; + gap: 0.55rem; } .schedule-conflict-slots { display: grid; - gap: 0.75rem; + gap: 0.55rem; max-height: none; overflow: visible; - padding-right: 0.25rem; + padding-right: 0; } .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; + grid-template-columns: minmax(160px, 1.05fr) minmax(104px, 0.72fr) minmax(112px, 0.72fr) minmax(132px, 0.85fr) minmax(190px, 1.2fr) minmax(160px, 0.95fr); + gap: 0.5rem; align-items: end; - padding: 0.75rem; + min-width: 0; + max-width: 100%; + padding: 0.6rem; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg-input) 88%, transparent); @@ -761,11 +799,51 @@ min-width: 0; } +.schedule-conflict-slot-fixed.schedule-conflict-field-hit { + padding-left: 0.55rem; + border-left: 3px solid rgba(245, 158, 11, 0.75); +} + .schedule-conflict-actions { display: flex; justify-content: flex-end; gap: 0.5rem; - padding-top: 0.25rem; + padding-top: 0; +} + +.schedule-conflict-card .form-group, +.schedule-move-slot-card .form-group { + min-width: 0; +} + +.schedule-conflict-card .form-group label, +.schedule-move-slot-card .form-group label { + margin-bottom: 0.28rem; + font-size: 0.7rem; +} + +.schedule-conflict-card .form-group select, +.schedule-move-slot-card .form-group select { + width: 100%; + max-width: 100%; + min-width: 0; + padding: 0.55rem 0.65rem; + font-size: 0.84rem; +} + +.schedule-conflict-card .form-alert, +.schedule-move-slot-card .form-alert { + margin-top: 0; +} + +.schedule-conflict-field-hit label { + color: var(--warning); +} + +.schedule-conflict-field-hit select { + border-color: rgba(245, 158, 11, 0.65); + background: color-mix(in srgb, rgba(245, 158, 11, 0.15) 35%, var(--bg-input)); + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.12); } .schedule-visual-context-menu { @@ -812,13 +890,24 @@ color: var(--error); } -.schedule-move-slot-card { +#schedule-move-slot-modal .schedule-move-slot-card { width: min(620px, calc(100vw - 2rem)); + box-sizing: border-box; + margin: auto; + max-height: none; + padding: 1rem; + overflow: visible; } .schedule-move-slot-form { display: grid; - gap: 0.9rem; + gap: 0.55rem; +} + +@media (max-width: 980px) { + .schedule-conflict-slot-row { + grid-template-columns: minmax(180px, 1.2fr) repeat(2, minmax(140px, 1fr)); + } } .subject-import-rows-list { @@ -1041,8 +1130,8 @@ width: 100%; } - .schedule-conflict-card { - padding: 1.25rem; + #schedule-conflict-modal .schedule-conflict-card { + padding: 1rem; } .schedule-conflict-rules, @@ -1471,31 +1560,31 @@ .schedule-visual-edit-rule { position: absolute; - top: 0.26rem; - right: 0.26rem; + top: 0.18rem; + right: 0.2rem; display: inline-flex; align-items: center; justify-content: center; - width: 1.1rem; - height: 1.1rem; + width: 1rem; + height: 1.25rem; padding: 0; - border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border)); - border-radius: 50%; - background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)); - color: var(--accent-hover); + border: 0; + border-radius: 0; + background: transparent; + color: var(--text-secondary); font: inherit; - font-size: 0.82rem; + font-size: 1rem; font-weight: 800; line-height: 1; cursor: pointer; - transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition); + box-shadow: none; + transition: color var(--transition), transform var(--transition); } .schedule-visual-edit-rule:hover, .schedule-visual-edit-rule:focus-visible { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 22%, var(--bg-card)); - color: var(--text-primary); + background: transparent; + color: var(--accent-hover); outline: none; transform: translateY(-1px); } diff --git a/frontend/admin/css/modals.css b/frontend/admin/css/modals.css index 2ba19c1..d2a5aaf 100644 --- a/frontend/admin/css/modals.css +++ b/frontend/admin/css/modals.css @@ -73,3 +73,29 @@ .modal-close:hover { color: var(--error); } + +#schedule-conflict-modal { + padding: 0.35rem; + overflow-x: hidden; + overflow-y: auto; +} + +#schedule-conflict-modal .modal-content { + width: min(1760px, calc(100vw - 0.7rem)); + max-width: calc(100vw - 0.7rem); + max-height: none; + box-sizing: border-box; + margin: auto; + padding: 1rem; + overflow: visible; +} + +#schedule-move-slot-modal .modal-content { + width: min(620px, calc(100vw - 2rem)); + max-width: calc(100vw - 2rem); + max-height: none; + box-sizing: border-box; + margin: auto; + padding: 1rem; + overflow: visible; +} diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 773db92..e57faab 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -94,6 +94,7 @@ export async function initSchedule() { const conflictSubmitButton = document.getElementById('schedule-conflict-submit'); const conflictSlotsContainer = document.getElementById('schedule-conflict-slots'); const conflictSummary = document.getElementById('schedule-conflict-summary'); + const conflictReasons = document.getElementById('schedule-conflict-reasons'); const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule'); const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta'); const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule'); @@ -1046,6 +1047,7 @@ export async function initSchedule() { const conflictRule = error.data.conflictRule; pendingConflictResolution = { conflictRule: clonePlain(conflictRule), + conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [], pendingPayload: clonePlain(pendingPayload), pendingRuleId: pendingRuleId || '' }; @@ -1064,7 +1066,8 @@ export async function initSchedule() { if (conflictExistingRuleMeta) { conflictExistingRuleMeta.textContent = ruleMeta(conflictRule); } - renderConflictSlots(conflictRule); + renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons); + renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields); hideAlert('schedule-conflict-alert'); conflictModal?.classList.add('open'); conflictModal?.setAttribute('aria-hidden', 'false'); @@ -1075,6 +1078,10 @@ export async function initSchedule() { conflictModal?.classList.remove('open'); conflictModal?.setAttribute('aria-hidden', 'true'); if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = ''; + if (conflictReasons) { + conflictReasons.hidden = true; + conflictReasons.innerHTML = ''; + } hideAlert('schedule-conflict-alert'); } @@ -1138,7 +1145,24 @@ export async function initSchedule() { } } - function renderConflictSlots(rule) { + function renderConflictReasons(fields, reasons) { + if (!conflictReasons) return; + const labels = Array.isArray(reasons) && reasons.length + ? reasons + : (fields || []).map(conflictFieldLabel).filter(Boolean); + if (!labels.length) { + conflictReasons.hidden = true; + conflictReasons.innerHTML = ''; + return; + } + conflictReasons.hidden = false; + conflictReasons.innerHTML = ` + Причина: + ${labels.map(label => `${escapeHtml(label)}`).join('')} + `; + } + + function renderConflictSlots(rule, conflictFields = []) { const slots = rule.slots || []; if (!conflictSlotsContainer) return; if (!slots.length) { @@ -1151,9 +1175,10 @@ export async function initSchedule() { slot.lessonFormat, slotSubgroupNames(slot).join(', ') ].filter(Boolean); + const groupHitClass = conflictFieldClass('group', conflictFields); return `
-
+
Слот ${index + 1} ${escapeHtml(fixedParts.join(' · ') || 'Занятие')} ${escapeHtml(conflictSlotCurrentText(slot))} @@ -1161,13 +1186,24 @@ export async function initSchedule() {
-
-
+
+
`; }).join(''); } + function conflictFieldClass(field, fields) { + return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : ''; + } + + function conflictFieldLabel(field) { + if (field === 'teacher') return 'Преподаватель'; + if (field === 'classroom') return 'Аудитория'; + if (field === 'group') return 'Группа'; + return field; + } + function readConflictSlots(validate) { const conflictRule = pendingConflictResolution?.conflictRule; const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []); diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 99d8ce5..a1d94df 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -98,6 +98,7 @@ Конфликт слота

Освободите место для нового правила

Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.

+