Исправил часть косяков в конструкторе правил

This commit is contained in:
dipatrik10
2026-05-31 21:36:23 +03:00
parent f3a9905423
commit 30972db9ee
5 changed files with 399 additions and 37 deletions

View File

@@ -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}`.

View File

@@ -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;
}
}

View File

@@ -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 || [])
};
}

View File

@@ -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}`)}">&#8942;</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) {

View File

@@ -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>