Исправил косяки в модалках решения конфликтов правил
This commit is contained in:
@@ -89,12 +89,9 @@ public class ScheduleRuleAdminController {
|
|||||||
try {
|
try {
|
||||||
ScheduleRule rule = new ScheduleRule();
|
ScheduleRule rule = new ScheduleRule();
|
||||||
applyRule(rule, request);
|
applyRule(rule, request);
|
||||||
ScheduleRule conflictRule = findConflictingRule(request, rule.getSlots(), null);
|
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
|
||||||
if (conflictRule != null) {
|
if (conflict != null) {
|
||||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
|
return conflictResponse(conflict);
|
||||||
"message", "Невозможно сохранить правило: слот занят",
|
|
||||||
"conflictRule", toDto(conflictRule)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||||
scheduleGeneratorService.clearCache();
|
scheduleGeneratorService.clearCache();
|
||||||
@@ -120,12 +117,9 @@ public class ScheduleRuleAdminController {
|
|||||||
try {
|
try {
|
||||||
ScheduleRule candidate = new ScheduleRule();
|
ScheduleRule candidate = new ScheduleRule();
|
||||||
applyRule(candidate, request);
|
applyRule(candidate, request);
|
||||||
ScheduleRule conflictRule = findConflictingRule(request, candidate.getSlots(), rule.getId());
|
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
|
||||||
if (conflictRule != null) {
|
if (conflict != null) {
|
||||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
|
return conflictResponse(conflict);
|
||||||
"message", "Невозможно сохранить правило: слот занят",
|
|
||||||
"conflictRule", toDto(conflictRule)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
applyRule(rule, request);
|
applyRule(rule, request);
|
||||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||||
@@ -205,34 +199,82 @@ public class ScheduleRuleAdminController {
|
|||||||
rule.getSlots().addAll(slots);
|
rule.getSlots().addAll(slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ScheduleRule findConflictingRule(ScheduleRuleDto request, Collection<ScheduleRuleSlot> 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<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
|
||||||
|
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
|
||||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||||
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
||||||
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
|
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
|
||||||
.filter(rule -> hasSlotConflict(newSlots, rule.getSlots()))
|
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
|
||||||
.min(Comparator.comparing(ScheduleRule::getId))
|
.filter(Objects::nonNull)
|
||||||
|
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasSlotConflict(Collection<ScheduleRuleSlot> newSlots, Collection<ScheduleRuleSlot> existingSlots) {
|
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
|
||||||
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
|
Collection<ScheduleRuleSlot> newSlots,
|
||||||
|
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
|
||||||
|
Collection<ScheduleRuleSlot> existingSlots) {
|
||||||
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
|
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
|
||||||
|
LinkedHashSet<String> fields = new LinkedHashSet<>();
|
||||||
for (ScheduleRuleSlot newSlot : newSlots) {
|
for (ScheduleRuleSlot newSlot : newSlots) {
|
||||||
for (ScheduleRuleSlot existingSlot : existingSlots) {
|
for (ScheduleRuleSlot existingSlot : existingSlots) {
|
||||||
if (sameTimeSlot(newSlot, existingSlot)
|
if (sameTimeSlot(newSlot, existingSlot)
|
||||||
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))
|
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
|
||||||
&& sameTeacherOrClassroomOrAudience(newSlot, existingSlot)) {
|
fields.addAll(conflictFields(newSlot, existingSlot));
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||||
|
LinkedHashSet<String> fields = new LinkedHashSet<>();
|
||||||
|
if (Objects.equals(teacherId(first), teacherId(second))) {
|
||||||
|
fields.add("teacher");
|
||||||
|
}
|
||||||
|
if (Objects.equals(classroomId(first), classroomId(second))) {
|
||||||
|
fields.add("classroom");
|
||||||
|
}
|
||||||
|
if (sameAudience(first, second)) {
|
||||||
|
fields.add("group");
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||||
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
|
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
|
||||||
&& parityOverlaps(first.getParity(), second.getParity())
|
&& 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) {
|
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
|
||||||
@@ -242,12 +284,6 @@ public class ScheduleRuleAdminController {
|
|||||||
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
|
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) {
|
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||||
Set<Long> firstGroupIds = groupIds(first);
|
Set<Long> firstGroupIds = groupIds(first);
|
||||||
Set<Long> secondGroupIds = groupIds(second);
|
Set<Long> secondGroupIds = groupIds(second);
|
||||||
@@ -622,4 +658,7 @@ public class ScheduleRuleAdminController {
|
|||||||
.thenComparing(Subgroup::getName))
|
.thenComparing(Subgroup::getName))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,10 +495,14 @@ CRUD доступен по:
|
|||||||
"semesterId": 1,
|
"semesterId": 1,
|
||||||
"groupNames": ["ИБ-101"],
|
"groupNames": ["ИБ-101"],
|
||||||
"slots": []
|
"slots": []
|
||||||
}
|
},
|
||||||
|
"conflictFields": ["classroom"],
|
||||||
|
"conflictReasons": ["Аудитория"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса.
|
||||||
|
|
||||||
### `GET /api/lesson-types`
|
### `GET /api/lesson-types`
|
||||||
|
|
||||||
Справочник типов занятий для конструктора правил расписания.
|
Справочник типов занятий для конструктора правил расписания.
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ frontend/
|
|||||||
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
- Вкладка `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`.
|
- Вкладка `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` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||||
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
||||||
|
|||||||
@@ -644,18 +644,26 @@
|
|||||||
|
|
||||||
.schedule-conflict-modal {
|
.schedule-conflict-modal {
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
|
padding: 0.35rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-card {
|
#schedule-conflict-modal .schedule-conflict-card {
|
||||||
width: min(1440px, calc(100vw - 2rem));
|
width: min(1760px, calc(100vw - 0.7rem));
|
||||||
padding: 1.5rem;
|
max-width: calc(100vw - 0.7rem);
|
||||||
|
max-height: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-head {
|
.schedule-conflict-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.45rem;
|
gap: 0.3rem;
|
||||||
margin-right: 2rem;
|
margin-right: 2rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-head h2 {
|
.schedule-conflict-head h2 {
|
||||||
@@ -669,7 +677,7 @@
|
|||||||
.schedule-conflict-head p {
|
.schedule-conflict-head p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.45;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-kicker {
|
.schedule-conflict-kicker {
|
||||||
@@ -685,18 +693,46 @@
|
|||||||
letter-spacing: 0.04em;
|
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 {
|
.schedule-conflict-rules {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-rule-box {
|
.schedule-conflict-rule-box {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.85rem;
|
padding: 0.65rem;
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
@@ -731,23 +767,25 @@
|
|||||||
|
|
||||||
.schedule-conflict-form {
|
.schedule-conflict-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-slots {
|
.schedule-conflict-slots {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.55rem;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
padding-right: 0.25rem;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-slot-row {
|
.schedule-conflict-slot-row {
|
||||||
display: grid;
|
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);
|
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.65rem;
|
gap: 0.5rem;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
padding: 0.75rem;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: color-mix(in srgb, var(--bg-input) 88%, transparent);
|
background: color-mix(in srgb, var(--bg-input) 88%, transparent);
|
||||||
@@ -761,11 +799,51 @@
|
|||||||
min-width: 0;
|
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 {
|
.schedule-conflict-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
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 {
|
.schedule-visual-context-menu {
|
||||||
@@ -812,13 +890,24 @@
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-move-slot-card {
|
#schedule-move-slot-modal .schedule-move-slot-card {
|
||||||
width: min(620px, calc(100vw - 2rem));
|
width: min(620px, calc(100vw - 2rem));
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: auto;
|
||||||
|
max-height: none;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-move-slot-form {
|
.schedule-move-slot-form {
|
||||||
display: grid;
|
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 {
|
.subject-import-rows-list {
|
||||||
@@ -1041,8 +1130,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-card {
|
#schedule-conflict-modal .schedule-conflict-card {
|
||||||
padding: 1.25rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-conflict-rules,
|
.schedule-conflict-rules,
|
||||||
@@ -1471,31 +1560,31 @@
|
|||||||
|
|
||||||
.schedule-visual-edit-rule {
|
.schedule-visual-edit-rule {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.26rem;
|
top: 0.18rem;
|
||||||
right: 0.26rem;
|
right: 0.2rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.1rem;
|
width: 1rem;
|
||||||
height: 1.1rem;
|
height: 1.25rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border));
|
border: 0;
|
||||||
border-radius: 50%;
|
border-radius: 0;
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-card));
|
background: transparent;
|
||||||
color: var(--accent-hover);
|
color: var(--text-secondary);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.82rem;
|
font-size: 1rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
|
box-shadow: none;
|
||||||
|
transition: color var(--transition), transform var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-visual-edit-rule:hover,
|
.schedule-visual-edit-rule:hover,
|
||||||
.schedule-visual-edit-rule:focus-visible {
|
.schedule-visual-edit-rule:focus-visible {
|
||||||
border-color: var(--accent);
|
background: transparent;
|
||||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-card));
|
color: var(--accent-hover);
|
||||||
color: var(--text-primary);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,3 +73,29 @@
|
|||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
color: var(--error);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export async function initSchedule() {
|
|||||||
const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
|
const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
|
||||||
const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
|
const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
|
||||||
const conflictSummary = document.getElementById('schedule-conflict-summary');
|
const conflictSummary = document.getElementById('schedule-conflict-summary');
|
||||||
|
const conflictReasons = document.getElementById('schedule-conflict-reasons');
|
||||||
const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
|
const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
|
||||||
const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
|
const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
|
||||||
const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
|
const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
|
||||||
@@ -1046,6 +1047,7 @@ export async function initSchedule() {
|
|||||||
const conflictRule = error.data.conflictRule;
|
const conflictRule = error.data.conflictRule;
|
||||||
pendingConflictResolution = {
|
pendingConflictResolution = {
|
||||||
conflictRule: clonePlain(conflictRule),
|
conflictRule: clonePlain(conflictRule),
|
||||||
|
conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [],
|
||||||
pendingPayload: clonePlain(pendingPayload),
|
pendingPayload: clonePlain(pendingPayload),
|
||||||
pendingRuleId: pendingRuleId || ''
|
pendingRuleId: pendingRuleId || ''
|
||||||
};
|
};
|
||||||
@@ -1064,7 +1066,8 @@ export async function initSchedule() {
|
|||||||
if (conflictExistingRuleMeta) {
|
if (conflictExistingRuleMeta) {
|
||||||
conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
|
conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
|
||||||
}
|
}
|
||||||
renderConflictSlots(conflictRule);
|
renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons);
|
||||||
|
renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields);
|
||||||
hideAlert('schedule-conflict-alert');
|
hideAlert('schedule-conflict-alert');
|
||||||
conflictModal?.classList.add('open');
|
conflictModal?.classList.add('open');
|
||||||
conflictModal?.setAttribute('aria-hidden', 'false');
|
conflictModal?.setAttribute('aria-hidden', 'false');
|
||||||
@@ -1075,6 +1078,10 @@ export async function initSchedule() {
|
|||||||
conflictModal?.classList.remove('open');
|
conflictModal?.classList.remove('open');
|
||||||
conflictModal?.setAttribute('aria-hidden', 'true');
|
conflictModal?.setAttribute('aria-hidden', 'true');
|
||||||
if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
|
if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
|
||||||
|
if (conflictReasons) {
|
||||||
|
conflictReasons.hidden = true;
|
||||||
|
conflictReasons.innerHTML = '';
|
||||||
|
}
|
||||||
hideAlert('schedule-conflict-alert');
|
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 = `
|
||||||
|
<span>Причина:</span>
|
||||||
|
${labels.map(label => `<strong>${escapeHtml(label)}</strong>`).join('')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConflictSlots(rule, conflictFields = []) {
|
||||||
const slots = rule.slots || [];
|
const slots = rule.slots || [];
|
||||||
if (!conflictSlotsContainer) return;
|
if (!conflictSlotsContainer) return;
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
@@ -1151,9 +1175,10 @@ export async function initSchedule() {
|
|||||||
slot.lessonFormat,
|
slot.lessonFormat,
|
||||||
slotSubgroupNames(slot).join(', ')
|
slotSubgroupNames(slot).join(', ')
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
const groupHitClass = conflictFieldClass('group', conflictFields);
|
||||||
return `
|
return `
|
||||||
<section class="schedule-conflict-slot-row" data-index="${index}">
|
<section class="schedule-conflict-slot-row" data-index="${index}">
|
||||||
<div class="schedule-conflict-slot-fixed">
|
<div class="schedule-conflict-slot-fixed ${groupHitClass}">
|
||||||
<span>Слот ${index + 1}</span>
|
<span>Слот ${index + 1}</span>
|
||||||
<strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
|
<strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
|
||||||
<small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
|
<small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
|
||||||
@@ -1161,13 +1186,24 @@ export async function initSchedule() {
|
|||||||
<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-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-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-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 ${conflictFieldClass('teacher', conflictFields)}"><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>
|
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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) {
|
function readConflictSlots(validate) {
|
||||||
const conflictRule = pendingConflictResolution?.conflictRule;
|
const conflictRule = pendingConflictResolution?.conflictRule;
|
||||||
const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
|
const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
<span class="schedule-conflict-kicker">Конфликт слота</span>
|
<span class="schedule-conflict-kicker">Конфликт слота</span>
|
||||||
<h2>Освободите место для нового правила</h2>
|
<h2>Освободите место для нового правила</h2>
|
||||||
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
|
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
|
||||||
|
<div class="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="schedule-conflict-rules">
|
<div class="schedule-conflict-rules">
|
||||||
<section class="schedule-conflict-rule-box">
|
<section class="schedule-conflict-rule-box">
|
||||||
|
|||||||
Reference in New Issue
Block a user