Исправил часть косяков в конструкторе правил
This commit is contained in:
@@ -148,10 +148,10 @@ frontend/
|
|||||||
|
|
||||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||||
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку.
|
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
||||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
||||||
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||||
|
|||||||
@@ -313,6 +313,65 @@
|
|||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-rule-table-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 188px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule-table-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 86px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0.38rem 0.72rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule-table-button:hover,
|
||||||
|
.schedule-rule-table-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule-table-button-edit {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--bg-card-border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg-input));
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule-table-button-edit:hover,
|
||||||
|
.schedule-rule-table-button-edit:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 22%, var(--bg-input));
|
||||||
|
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent-glow) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule-table-button-delete {
|
||||||
|
border-color: color-mix(in srgb, var(--error) 34%, var(--bg-card-border));
|
||||||
|
background: color-mix(in srgb, var(--error) 10%, var(--bg-input));
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule-table-button-delete:hover,
|
||||||
|
.schedule-rule-table-button-delete:focus-visible {
|
||||||
|
border-color: var(--error);
|
||||||
|
background: color-mix(in srgb, var(--error) 18%, var(--bg-input));
|
||||||
|
box-shadow: 0 4px 14px rgba(248, 113, 113, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
.slot-subgroup-multi .select-text {
|
.slot-subgroup-multi .select-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -429,6 +488,17 @@
|
|||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-visual-period-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-visual-period-grid .form-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-visual-filter-head {
|
.schedule-visual-filter-head {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -665,9 +735,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schedule-visual-lesson {
|
.schedule-visual-lesson {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.12rem;
|
gap: 0.12rem;
|
||||||
padding: 0.34rem;
|
padding: 0.34rem 1.65rem 0.34rem 0.34rem;
|
||||||
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-card);
|
background: var(--bg-card);
|
||||||
@@ -691,6 +762,37 @@
|
|||||||
line-height: 1.14;
|
line-height: 1.14;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-visual-edit-rule {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.26rem;
|
||||||
|
right: 0.26rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
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);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), border-color var(--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);
|
||||||
|
outline: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-visual-free {
|
.schedule-visual-free {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -720,6 +822,10 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-visual-period-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-fill-panel {
|
.calendar-fill-panel {
|
||||||
@@ -2284,4 +2390,4 @@ tbody tr:hover {
|
|||||||
padding: 0.05rem 0.35rem;
|
padding: 0.05rem 0.35rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const CELL_PARITY_LAYOUT = [
|
|||||||
{ parity: 'EVEN', label: 'Чётная' }
|
{ parity: 'EVEN', label: 'Чётная' }
|
||||||
];
|
];
|
||||||
const MOBILE_QUERY = '(max-width: 760px)';
|
const MOBILE_QUERY = '(max-width: 760px)';
|
||||||
|
const DEFAULT_SEMESTER_WEEKS = 22;
|
||||||
|
|
||||||
export async function initScheduleView() {
|
export async function initScheduleView() {
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
@@ -621,11 +622,43 @@ export async function initScheduleView() {
|
|||||||
|
|
||||||
if (startWeek <= 0) startWeek = 1;
|
if (startWeek <= 0) startWeek = 1;
|
||||||
if (endWeek < startWeek) endWeek = startWeek;
|
if (endWeek < startWeek) endWeek = startWeek;
|
||||||
|
|
||||||
if (startWeek === endWeek) {
|
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
|
||||||
return `${startWeek} нед.`;
|
}
|
||||||
}
|
|
||||||
return `с ${startWeek} по ${endWeek} нед.`;
|
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
|
||||||
|
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
|
||||||
|
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||||||
|
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
|
||||||
|
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
|
||||||
|
return startWeek === endWeek
|
||||||
|
? `(${startWeek} нед.)`
|
||||||
|
: `(с ${startWeek} по ${endWeek} нед.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function semesterWeeksForLesson(lesson) {
|
||||||
|
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
|
||||||
|
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
|
||||||
|
const start = parseIsoDate(semester.startDate);
|
||||||
|
const end = parseIsoDate(semester.endDate);
|
||||||
|
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||||
|
return Math.max(1, Math.ceil(days / 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateInRange(date, startDate, endDate) {
|
||||||
|
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstApplicableSemesterWeek(parity) {
|
||||||
|
return parity === 'EVEN' ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||||||
|
const totalWeeks = Number(maxWeeks || 0);
|
||||||
|
if (!totalWeeks) return 0;
|
||||||
|
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||||||
|
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||||||
|
return totalWeeks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLessonCard(lesson) {
|
function renderLessonCard(lesson) {
|
||||||
@@ -694,13 +727,14 @@ export async function initScheduleView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDictionaries(role, userDepartmentId) {
|
async function loadDictionaries(role, userDepartmentId) {
|
||||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([
|
const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([
|
||||||
api.get('/api/groups'),
|
api.get('/api/groups'),
|
||||||
api.get('/api/users/teachers'),
|
api.get('/api/users/teachers'),
|
||||||
api.get('/api/classrooms'),
|
api.get('/api/classrooms'),
|
||||||
api.get('/api/departments'),
|
api.get('/api/departments'),
|
||||||
api.get('/api/subjects'),
|
api.get('/api/subjects'),
|
||||||
api.get('/api/lesson-types')
|
api.get('/api/lesson-types'),
|
||||||
|
api.get('/api/admin/calendar/years').catch(() => [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
|
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
|
||||||
@@ -718,7 +752,8 @@ async function loadDictionaries(role, userDepartmentId) {
|
|||||||
groups,
|
groups,
|
||||||
teachers,
|
teachers,
|
||||||
classrooms,
|
classrooms,
|
||||||
departments
|
departments,
|
||||||
|
semesters: (academicYears || []).flatMap(year => year.semesters || [])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export async function initSchedule() {
|
|||||||
const visualBackdrop = document.getElementById('schedule-visual-backdrop');
|
const visualBackdrop = document.getElementById('schedule-visual-backdrop');
|
||||||
const visualStage = document.getElementById('schedule-visual-stage');
|
const visualStage = document.getElementById('schedule-visual-stage');
|
||||||
const visualSummary = document.getElementById('schedule-visual-summary');
|
const visualSummary = document.getElementById('schedule-visual-summary');
|
||||||
|
const visualYearSelect = document.getElementById('schedule-visual-year');
|
||||||
|
const visualSemesterSelect = document.getElementById('schedule-visual-semester');
|
||||||
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
|
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
|
||||||
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
|
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
|
||||||
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
|
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
|
||||||
@@ -94,9 +96,14 @@ export async function initSchedule() {
|
|||||||
let lessonTypes = [];
|
let lessonTypes = [];
|
||||||
let subgroups = [];
|
let subgroups = [];
|
||||||
let timeSlots = [];
|
let timeSlots = [];
|
||||||
|
let academicYears = [];
|
||||||
let semesters = [];
|
let semesters = [];
|
||||||
|
let selectedVisualYearId = '';
|
||||||
|
let selectedVisualSemesterId = '';
|
||||||
let selectedVisualGroupIds = new Set();
|
let selectedVisualGroupIds = new Set();
|
||||||
let visualGroupFilterTouched = false;
|
let visualGroupFilterTouched = false;
|
||||||
|
let visualPeriodTouched = false;
|
||||||
|
let syncingVisualPeriod = false;
|
||||||
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
|
||||||
@@ -116,17 +123,30 @@ export async function initSchedule() {
|
|||||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||||||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||||||
|
visualYearSelect?.addEventListener('change', handleVisualYearChange);
|
||||||
|
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||||||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||||||
|
visualStage?.addEventListener('click', handleVisualStageClick);
|
||||||
visualUseFormButton?.addEventListener('click', () => {
|
visualUseFormButton?.addEventListener('click', () => {
|
||||||
|
const formSemesterId = ruleSemesterSelect.value;
|
||||||
|
if (formSemesterId) {
|
||||||
|
visualPeriodTouched = true;
|
||||||
|
setVisualPeriodBySemesterId(formSemesterId);
|
||||||
|
}
|
||||||
visualGroupFilterTouched = false;
|
visualGroupFilterTouched = false;
|
||||||
selectedVisualGroupIds = new Set();
|
selectedVisualGroupIds = new Set();
|
||||||
renderVisualSchedule();
|
renderVisualSchedule();
|
||||||
});
|
});
|
||||||
visualSelectAllButton?.addEventListener('click', () => {
|
visualSelectAllButton?.addEventListener('click', () => {
|
||||||
selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id)));
|
selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id)));
|
||||||
visualGroupFilterTouched = true;
|
visualGroupFilterTouched = true;
|
||||||
renderVisualSchedule();
|
renderVisualSchedule();
|
||||||
});
|
});
|
||||||
|
ruleSemesterSelect?.addEventListener('change', () => {
|
||||||
|
if (visualPeriodTouched) return;
|
||||||
|
selectDefaultVisualPeriod();
|
||||||
|
renderVisualSchedule();
|
||||||
|
});
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape') closeVisualDrawer();
|
if (event.key === 'Escape') closeVisualDrawer();
|
||||||
});
|
});
|
||||||
@@ -190,15 +210,19 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadYears() {
|
async function loadYears() {
|
||||||
const academicYears = await api.get('/api/admin/calendar/years');
|
academicYears = await api.get('/api/admin/calendar/years');
|
||||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||||
populateSemesterSelects();
|
populateSemesterSelects();
|
||||||
|
selectDefaultVisualPeriod();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||||
try {
|
try {
|
||||||
rules = await api.get('/api/admin/schedule-rules');
|
rules = await api.get('/api/admin/schedule-rules');
|
||||||
|
if (!visualPeriodTouched) {
|
||||||
|
selectDefaultVisualPeriod();
|
||||||
|
}
|
||||||
renderRules();
|
renderRules();
|
||||||
renderVisualSchedule();
|
renderVisualSchedule();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -222,8 +246,10 @@ export async function initSchedule() {
|
|||||||
<td>${renderRuleLoadSummary(rule)}</td>
|
<td>${renderRuleLoadSummary(rule)}</td>
|
||||||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
<div class="schedule-rule-table-actions">
|
||||||
<button class="btn-delete btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
<button type="button" class="schedule-rule-table-button schedule-rule-table-button-edit btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||||
|
<button type="button" class="schedule-rule-table-button schedule-rule-table-button-delete btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -262,16 +288,32 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function renderVisualSchedule() {
|
function renderVisualSchedule() {
|
||||||
if (!visualStage || !visualSummary) return;
|
if (!visualStage || !visualSummary) return;
|
||||||
|
if (!selectedVisualSemesterId) {
|
||||||
|
syncVisualGroupFilter([]);
|
||||||
|
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||||||
|
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!rules.length) {
|
if (!rules.length) {
|
||||||
|
syncVisualGroupFilter([]);
|
||||||
visualSummary.textContent = 'Нет сохранённых правил';
|
visualSummary.textContent = 'Нет сохранённых правил';
|
||||||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allGroups = collectVisualGroups();
|
const visualRules = visualRulesForSelectedPeriod();
|
||||||
|
const periodLabel = visualPeriodLabel();
|
||||||
|
if (!visualRules.length) {
|
||||||
|
syncVisualGroupFilter([]);
|
||||||
|
visualSummary.textContent = `${periodLabel} · правил нет`;
|
||||||
|
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allGroups = collectVisualGroups(visualRules);
|
||||||
syncVisualGroupFilter(allGroups);
|
syncVisualGroupFilter(allGroups);
|
||||||
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
||||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules();
|
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
|
||||||
if (!allGroups.length || !slots.length) {
|
if (!allGroups.length || !slots.length) {
|
||||||
visualSummary.textContent = 'Недостаточно данных для построения';
|
visualSummary.textContent = 'Недостаточно данных для построения';
|
||||||
setVisualState('У правил должны быть группы и слоты занятий');
|
setVisualState('У правил должны быть группы и слоты занятий');
|
||||||
@@ -283,15 +325,15 @@ export async function initSchedule() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds);
|
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
||||||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||||
setVisualState('Для выбранных групп нет активных пар в сохранённых правилах');
|
setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
visualSummary.textContent = `${rules.length} ${ruleCountLabel(rules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||||
visualStage.innerHTML = `
|
visualStage.innerHTML = `
|
||||||
<div class="schedule-visual-table-wrap">
|
<div class="schedule-visual-table-wrap">
|
||||||
<table class="schedule-visual-table">
|
<table class="schedule-visual-table">
|
||||||
@@ -348,10 +390,10 @@ export async function initSchedule() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVisualLessonsByCell(allowedGroupIds) {
|
function buildVisualLessonsByCell(allowedGroupIds, visualRules) {
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
const activeWeeks = buildActiveWeeksByRuleSlot();
|
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
|
||||||
rules.forEach(rule => {
|
visualRules.forEach(rule => {
|
||||||
(rule.slots || []).forEach((slot, index) => {
|
(rule.slots || []).forEach((slot, index) => {
|
||||||
const slotKey = visualRuleSlotKey(slot, index);
|
const slotKey = visualRuleSlotKey(slot, index);
|
||||||
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
||||||
@@ -360,6 +402,7 @@ export async function initSchedule() {
|
|||||||
if (!weeks.length) return;
|
if (!weeks.length) return;
|
||||||
const lesson = {
|
const lesson = {
|
||||||
id: `${rule.id}:${slotKey}:${groupId}`,
|
id: `${rule.id}:${slotKey}:${groupId}`,
|
||||||
|
ruleId: rule.id,
|
||||||
parity: slot.parity || 'BOTH',
|
parity: slot.parity || 'BOTH',
|
||||||
subjectName: rule.subjectName || 'Без дисциплины',
|
subjectName: rule.subjectName || 'Без дисциплины',
|
||||||
teacherName: slot.teacherName || '',
|
teacherName: slot.teacherName || '',
|
||||||
@@ -367,7 +410,7 @@ export async function initSchedule() {
|
|||||||
lessonTypeName: slot.lessonTypeName || '',
|
lessonTypeName: slot.lessonTypeName || '',
|
||||||
lessonFormat: slot.lessonFormat || '',
|
lessonFormat: slot.lessonFormat || '',
|
||||||
subgroupNames: slotSubgroupNames(slot),
|
subgroupNames: slotSubgroupNames(slot),
|
||||||
weekText: compactWeekRanges(weeks),
|
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
|
||||||
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
|
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
|
||||||
};
|
};
|
||||||
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
||||||
@@ -380,9 +423,9 @@ export async function initSchedule() {
|
|||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildActiveWeeksByRuleSlot() {
|
function buildActiveWeeksByRuleSlot(visualRules) {
|
||||||
const activeWeeks = new Map();
|
const activeWeeks = new Map();
|
||||||
rules.forEach(rule => {
|
visualRules.forEach(rule => {
|
||||||
const maxWeeks = maxWeeksForRule(rule);
|
const maxWeeks = maxWeeksForRule(rule);
|
||||||
(rule.groupIds || []).forEach(groupId => {
|
(rule.groupIds || []).forEach(groupId => {
|
||||||
LESSON_TYPE_LIMITS.forEach(config => {
|
LESSON_TYPE_LIMITS.forEach(config => {
|
||||||
@@ -454,13 +497,18 @@ export async function initSchedule() {
|
|||||||
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
|
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
|
||||||
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
|
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
|
||||||
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||||||
|
<button type="button"
|
||||||
|
class="schedule-visual-edit-rule"
|
||||||
|
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
|
||||||
|
title="Изменить правило"
|
||||||
|
aria-label="${escapeHtml(`Изменить правило: ${lesson.subjectName}`)}">⋮</button>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectVisualGroups() {
|
function collectVisualGroups(visualRules = rules) {
|
||||||
const byId = new Map();
|
const byId = new Map();
|
||||||
rules.forEach(rule => {
|
visualRules.forEach(rule => {
|
||||||
(rule.groupIds || []).forEach((id, index) => {
|
(rule.groupIds || []).forEach((id, index) => {
|
||||||
if (!byId.has(String(id))) {
|
if (!byId.has(String(id))) {
|
||||||
byId.set(String(id), {
|
byId.set(String(id), {
|
||||||
@@ -473,9 +521,9 @@ export async function initSchedule() {
|
|||||||
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
|
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectVisualSlotsFromRules() {
|
function collectVisualSlotsFromRules(visualRules = rules) {
|
||||||
const byKey = new Map();
|
const byKey = new Map();
|
||||||
rules.flatMap(rule => rule.slots || []).forEach(slot => {
|
visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
|
||||||
const key = visualSlotKey(slot);
|
const key = visualSlotKey(slot);
|
||||||
if (!byKey.has(key)) {
|
if (!byKey.has(key)) {
|
||||||
byKey.set(key, {
|
byKey.set(key, {
|
||||||
@@ -511,6 +559,39 @@ export async function initSchedule() {
|
|||||||
renderVisualSchedule();
|
renderVisualSchedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleVisualYearChange() {
|
||||||
|
if (syncingVisualPeriod) return;
|
||||||
|
selectedVisualYearId = visualYearSelect?.value || '';
|
||||||
|
selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id
|
||||||
|
? String(firstSemesterForYear(selectedVisualYearId).id)
|
||||||
|
: '';
|
||||||
|
visualPeriodTouched = true;
|
||||||
|
visualGroupFilterTouched = false;
|
||||||
|
populateVisualSemesterSelect();
|
||||||
|
renderVisualSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisualSemesterChange() {
|
||||||
|
if (syncingVisualPeriod) return;
|
||||||
|
selectedVisualSemesterId = visualSemesterSelect?.value || '';
|
||||||
|
visualPeriodTouched = true;
|
||||||
|
visualGroupFilterTouched = false;
|
||||||
|
renderVisualSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
if (!rule) {
|
||||||
|
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fillRuleForm(rule);
|
||||||
|
closeVisualDrawer();
|
||||||
|
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
function syncVisualGroupFilter(allGroups) {
|
function syncVisualGroupFilter(allGroups) {
|
||||||
ensureVisualGroupSelection(allGroups);
|
ensureVisualGroupSelection(allGroups);
|
||||||
if (!visualGroupFilter) return;
|
if (!visualGroupFilter) return;
|
||||||
@@ -547,6 +628,92 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectDefaultVisualPeriod() {
|
||||||
|
const formSemester = semesterById(ruleSemesterSelect?.value);
|
||||||
|
const ruleSemester = firstSemesterWithRules();
|
||||||
|
const fallbackSemester = semesters[0];
|
||||||
|
const semester = formSemester || ruleSemester || fallbackSemester;
|
||||||
|
setVisualPeriodBySemesterId(semester?.id || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisualPeriodBySemesterId(semesterId) {
|
||||||
|
const semester = semesterById(semesterId);
|
||||||
|
selectedVisualSemesterId = semester ? String(semester.id) : '';
|
||||||
|
selectedVisualYearId = semester ? String(semester.academicYearId) : '';
|
||||||
|
populateVisualYearSelect();
|
||||||
|
populateVisualSemesterSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateVisualYearSelect() {
|
||||||
|
if (!visualYearSelect) return;
|
||||||
|
visualYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||||
|
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||||
|
if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) {
|
||||||
|
visualYearSelect.value = selectedVisualYearId;
|
||||||
|
} else if (academicYears.length) {
|
||||||
|
selectedVisualYearId = String(academicYears[0].id);
|
||||||
|
visualYearSelect.value = selectedVisualYearId;
|
||||||
|
} else {
|
||||||
|
selectedVisualYearId = '';
|
||||||
|
visualYearSelect.value = '';
|
||||||
|
}
|
||||||
|
syncVisualPeriodSelects(visualYearSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateVisualSemesterSelect() {
|
||||||
|
if (!visualSemesterSelect) return;
|
||||||
|
const yearSemesters = semestersForYear(selectedVisualYearId);
|
||||||
|
visualSemesterSelect.innerHTML = yearSemesters.length
|
||||||
|
? '<option value="">Выберите семестр</option>' + yearSemesters
|
||||||
|
.map(semester => `<option value="${semester.id}">${escapeHtml(shortSemesterLabel(semester))}</option>`)
|
||||||
|
.join('')
|
||||||
|
: '<option value="">Нет семестров</option>';
|
||||||
|
|
||||||
|
if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) {
|
||||||
|
visualSemesterSelect.value = selectedVisualSemesterId;
|
||||||
|
} else if (yearSemesters.length) {
|
||||||
|
selectedVisualSemesterId = String(yearSemesters[0].id);
|
||||||
|
visualSemesterSelect.value = selectedVisualSemesterId;
|
||||||
|
} else {
|
||||||
|
selectedVisualSemesterId = '';
|
||||||
|
visualSemesterSelect.value = '';
|
||||||
|
}
|
||||||
|
syncVisualPeriodSelects(visualSemesterSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVisualPeriodSelects(...selects) {
|
||||||
|
syncingVisualPeriod = true;
|
||||||
|
syncSelects(...selects);
|
||||||
|
syncingVisualPeriod = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualRulesForSelectedPeriod() {
|
||||||
|
if (!selectedVisualSemesterId) return [];
|
||||||
|
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstSemesterWithRules() {
|
||||||
|
const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId)));
|
||||||
|
return semesters.find(semester => ruleSemesterIds.has(String(semester.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function semesterById(id) {
|
||||||
|
return semesters.find(semester => String(semester.id) === String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function semestersForYear(yearId) {
|
||||||
|
return semesters.filter(semester => String(semester.academicYearId) === String(yearId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstSemesterForYear(yearId) {
|
||||||
|
return semestersForYear(yearId)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualPeriodLabel() {
|
||||||
|
const semester = semesterById(selectedVisualSemesterId);
|
||||||
|
return semester ? shortSemesterLabel(semester) : 'Семестр не выбран';
|
||||||
|
}
|
||||||
|
|
||||||
function renderRuleSlotsSummary(slots) {
|
function renderRuleSlotsSummary(slots) {
|
||||||
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
||||||
return slots.map(slot => {
|
return slots.map(slot => {
|
||||||
@@ -687,7 +854,7 @@ export async function initSchedule() {
|
|||||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||||
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||||
@@ -1022,6 +1189,23 @@ export async function initSchedule() {
|
|||||||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderedLessonTypes() {
|
||||||
|
return [...lessonTypes].sort(compareLessonTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLessonTypes(first, second) {
|
||||||
|
return lessonTypeRank(first) - lessonTypeRank(second)
|
||||||
|
|| naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonTypeRank(lessonType) {
|
||||||
|
const key = lessonTypeKey(lessonType);
|
||||||
|
if (key === 'lecture') return 0;
|
||||||
|
if (key === 'laboratory') return 1;
|
||||||
|
if (key === 'practice') return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
function lessonTypeKeyById(lessonTypeId) {
|
function lessonTypeKeyById(lessonTypeId) {
|
||||||
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
||||||
return lessonType ? lessonTypeKey(lessonType) : null;
|
return lessonType ? lessonTypeKey(lessonType) : null;
|
||||||
@@ -1048,6 +1232,11 @@ export async function initSchedule() {
|
|||||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortSemesterLabel(semester) {
|
||||||
|
const type = normalizeSemesterType(semester.semesterType);
|
||||||
|
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatSemester(rule) {
|
function formatSemester(rule) {
|
||||||
const type = normalizeSemesterType(rule.semesterType);
|
const type = normalizeSemesterType(rule.semesterType);
|
||||||
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
||||||
@@ -1106,16 +1295,34 @@ export async function initSchedule() {
|
|||||||
return new Date(year, month - 1, day);
|
return new Date(year, month - 1, day);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactWeekRanges(weeks) {
|
function compactWeekRanges(weeks, maxWeeks, parity) {
|
||||||
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
||||||
if (!uniqueWeeks.length) return '';
|
if (!uniqueWeeks.length) return '';
|
||||||
return `(${weekRangeText(uniqueWeeks[0], uniqueWeeks[uniqueWeeks.length - 1])})`;
|
const start = uniqueWeeks[0];
|
||||||
|
const end = uniqueWeeks[uniqueWeeks.length - 1];
|
||||||
|
const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
|
||||||
|
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||||||
|
if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
|
||||||
|
if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
|
||||||
|
return weekRangeText(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
function weekRangeText(start, end) {
|
function weekRangeText(start, end) {
|
||||||
return start === end
|
return start === end
|
||||||
? `${start} нед.`
|
? `(${start} нед.)`
|
||||||
: `с ${start} по ${end} нед.`;
|
: `(с ${start} по ${end} нед.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstApplicableSemesterWeek(parity) {
|
||||||
|
return parity === 'EVEN' ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||||||
|
const totalWeeks = Number(maxWeeks || 0);
|
||||||
|
if (!totalWeeks) return 0;
|
||||||
|
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||||||
|
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||||||
|
return totalWeeks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareSlotItems(first, second) {
|
function compareSlotItems(first, second) {
|
||||||
|
|||||||
@@ -136,6 +136,20 @@
|
|||||||
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button>
|
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="schedule-visual-toolbar">
|
<div class="schedule-visual-toolbar">
|
||||||
|
<div class="schedule-visual-period-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-visual-year">Учебный год</label>
|
||||||
|
<select id="schedule-visual-year">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-visual-semester">Семестр</label>
|
||||||
|
<select id="schedule-visual-semester">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="schedule-visual-filter-head">
|
<div class="schedule-visual-filter-head">
|
||||||
<span>Группы</span>
|
<span>Группы</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user