Исправил часть косяков в конструкторе правил
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`.
|
||||
- Вкладка `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`.
|
||||
- Вкладка `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` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||
|
||||
@@ -313,6 +313,65 @@
|
||||
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 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -429,6 +488,17 @@
|
||||
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 {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -665,9 +735,10 @@
|
||||
}
|
||||
|
||||
.schedule-visual-lesson {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
padding: 0.34rem;
|
||||
padding: 0.34rem 1.65rem 0.34rem 0.34rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
@@ -691,6 +762,37 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -720,6 +822,10 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-visual-period-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-fill-panel {
|
||||
@@ -2284,4 +2390,4 @@ tbody tr:hover {
|
||||
padding: 0.05rem 0.35rem;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const CELL_PARITY_LAYOUT = [
|
||||
{ parity: 'EVEN', label: 'Чётная' }
|
||||
];
|
||||
const MOBILE_QUERY = '(max-width: 760px)';
|
||||
const DEFAULT_SEMESTER_WEEKS = 22;
|
||||
|
||||
export async function initScheduleView() {
|
||||
const role = localStorage.getItem('role');
|
||||
@@ -621,11 +622,43 @@ export async function initScheduleView() {
|
||||
|
||||
if (startWeek <= 0) startWeek = 1;
|
||||
if (endWeek < startWeek) endWeek = startWeek;
|
||||
|
||||
if (startWeek === endWeek) {
|
||||
return `${startWeek} нед.`;
|
||||
}
|
||||
return `с ${startWeek} по ${endWeek} нед.`;
|
||||
|
||||
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -694,13 +727,14 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
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/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/departments'),
|
||||
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
|
||||
@@ -718,7 +752,8 @@ async function loadDictionaries(role, userDepartmentId) {
|
||||
groups,
|
||||
teachers,
|
||||
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 visualStage = document.getElementById('schedule-visual-stage');
|
||||
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 visualUseFormButton = document.getElementById('schedule-visual-use-form');
|
||||
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
|
||||
@@ -94,9 +96,14 @@ export async function initSchedule() {
|
||||
let lessonTypes = [];
|
||||
let subgroups = [];
|
||||
let timeSlots = [];
|
||||
let academicYears = [];
|
||||
let semesters = [];
|
||||
let selectedVisualYearId = '';
|
||||
let selectedVisualSemesterId = '';
|
||||
let selectedVisualGroupIds = new Set();
|
||||
let visualGroupFilterTouched = false;
|
||||
let visualPeriodTouched = false;
|
||||
let syncingVisualPeriod = false;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -116,17 +123,30 @@ export async function initSchedule() {
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||||
visualYearSelect?.addEventListener('change', handleVisualYearChange);
|
||||
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||||
visualStage?.addEventListener('click', handleVisualStageClick);
|
||||
visualUseFormButton?.addEventListener('click', () => {
|
||||
const formSemesterId = ruleSemesterSelect.value;
|
||||
if (formSemesterId) {
|
||||
visualPeriodTouched = true;
|
||||
setVisualPeriodBySemesterId(formSemesterId);
|
||||
}
|
||||
visualGroupFilterTouched = false;
|
||||
selectedVisualGroupIds = new Set();
|
||||
renderVisualSchedule();
|
||||
});
|
||||
visualSelectAllButton?.addEventListener('click', () => {
|
||||
selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id)));
|
||||
selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id)));
|
||||
visualGroupFilterTouched = true;
|
||||
renderVisualSchedule();
|
||||
});
|
||||
ruleSemesterSelect?.addEventListener('change', () => {
|
||||
if (visualPeriodTouched) return;
|
||||
selectDefaultVisualPeriod();
|
||||
renderVisualSchedule();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeVisualDrawer();
|
||||
});
|
||||
@@ -190,15 +210,19 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
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 || []);
|
||||
populateSemesterSelects();
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
rules = await api.get('/api/admin/schedule-rules');
|
||||
if (!visualPeriodTouched) {
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
renderRules();
|
||||
renderVisualSchedule();
|
||||
} catch (error) {
|
||||
@@ -222,8 +246,10 @@ export async function initSchedule() {
|
||||
<td>${renderRuleLoadSummary(rule)}</td>
|
||||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||||
<div class="schedule-rule-table-actions">
|
||||
<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>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -262,16 +288,32 @@ export async function initSchedule() {
|
||||
|
||||
function renderVisualSchedule() {
|
||||
if (!visualStage || !visualSummary) return;
|
||||
if (!selectedVisualSemesterId) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||||
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||||
return;
|
||||
}
|
||||
if (!rules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Нет сохранённых правил';
|
||||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||||
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);
|
||||
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) {
|
||||
visualSummary.textContent = 'Недостаточно данных для построения';
|
||||
setVisualState('У правил должны быть группы и слоты занятий');
|
||||
@@ -283,15 +325,15 @@ export async function initSchedule() {
|
||||
return;
|
||||
}
|
||||
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds);
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
||||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||||
if (!rows.length) {
|
||||
visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||
setVisualState('Для выбранных групп нет активных пар в сохранённых правилах');
|
||||
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||
setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
|
||||
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 = `
|
||||
<div class="schedule-visual-table-wrap">
|
||||
<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 activeWeeks = buildActiveWeeksByRuleSlot();
|
||||
rules.forEach(rule => {
|
||||
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
|
||||
visualRules.forEach(rule => {
|
||||
(rule.slots || []).forEach((slot, index) => {
|
||||
const slotKey = visualRuleSlotKey(slot, index);
|
||||
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
||||
@@ -360,6 +402,7 @@ export async function initSchedule() {
|
||||
if (!weeks.length) return;
|
||||
const lesson = {
|
||||
id: `${rule.id}:${slotKey}:${groupId}`,
|
||||
ruleId: rule.id,
|
||||
parity: slot.parity || 'BOTH',
|
||||
subjectName: rule.subjectName || 'Без дисциплины',
|
||||
teacherName: slot.teacherName || '',
|
||||
@@ -367,7 +410,7 @@ export async function initSchedule() {
|
||||
lessonTypeName: slot.lessonTypeName || '',
|
||||
lessonFormat: slot.lessonFormat || '',
|
||||
subgroupNames: slotSubgroupNames(slot),
|
||||
weekText: compactWeekRanges(weeks),
|
||||
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
|
||||
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
|
||||
};
|
||||
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
||||
@@ -380,9 +423,9 @@ export async function initSchedule() {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function buildActiveWeeksByRuleSlot() {
|
||||
function buildActiveWeeksByRuleSlot(visualRules) {
|
||||
const activeWeeks = new Map();
|
||||
rules.forEach(rule => {
|
||||
visualRules.forEach(rule => {
|
||||
const maxWeeks = maxWeeksForRule(rule);
|
||||
(rule.groupIds || []).forEach(groupId => {
|
||||
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>
|
||||
${subgroupText ? `<small>${escapeHtml(subgroupText)}</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>
|
||||
`;
|
||||
}
|
||||
|
||||
function collectVisualGroups() {
|
||||
function collectVisualGroups(visualRules = rules) {
|
||||
const byId = new Map();
|
||||
rules.forEach(rule => {
|
||||
visualRules.forEach(rule => {
|
||||
(rule.groupIds || []).forEach((id, index) => {
|
||||
if (!byId.has(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));
|
||||
}
|
||||
|
||||
function collectVisualSlotsFromRules() {
|
||||
function collectVisualSlotsFromRules(visualRules = rules) {
|
||||
const byKey = new Map();
|
||||
rules.flatMap(rule => rule.slots || []).forEach(slot => {
|
||||
visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
|
||||
const key = visualSlotKey(slot);
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
@@ -511,6 +559,39 @@ export async function initSchedule() {
|
||||
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) {
|
||||
ensureVisualGroupSelection(allGroups);
|
||||
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) {
|
||||
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
||||
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-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-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) : ''}
|
||||
<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>
|
||||
@@ -1022,6 +1189,23 @@ export async function initSchedule() {
|
||||
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) {
|
||||
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
||||
return lessonType ? lessonTypeKey(lessonType) : null;
|
||||
@@ -1048,6 +1232,11 @@ export async function initSchedule() {
|
||||
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) {
|
||||
const type = normalizeSemesterType(rule.semesterType);
|
||||
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
||||
@@ -1106,16 +1295,34 @@ export async function initSchedule() {
|
||||
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);
|
||||
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) {
|
||||
return start === end
|
||||
? `${start} нед.`
|
||||
: `с ${start} по ${end} нед.`;
|
||||
? `(${start} нед.)`
|
||||
: `(с ${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) {
|
||||
|
||||
@@ -136,6 +136,20 @@
|
||||
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button>
|
||||
</div>
|
||||
<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">
|
||||
<span>Группы</span>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user