import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник' },
{ value: 2, label: 'Вторник' },
{ value: 3, label: 'Среда' },
{ value: 4, label: 'Четверг' },
{ value: 5, label: 'Пятница' },
{ value: 6, label: 'Суббота' },
{ value: 7, label: 'Воскресенье' }
];
const PARITY_LABELS = {
BOTH: 'каждую неделю',
ODD: 'нечётная',
EVEN: 'чётная'
};
const CELL_PARITY_LAYOUT = [
{ parity: 'ODD', label: 'Нечётная' },
{ parity: 'EVEN', label: 'Чётная' }
];
const SEMESTER_LABELS = {
autumn: 'осенний',
spring: 'весенний'
};
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2;
const LESSON_TYPE_LIMITS = [
{
key: 'lecture',
label: 'Лекции',
hoursField: 'lectureAcademicHours',
startWeekField: 'lectureStartWeek',
hoursInputId: 'schedule-rule-lecture-hours',
startWeekInputId: 'schedule-rule-lecture-start-week'
},
{
key: 'laboratory',
label: 'Лабораторные',
hoursField: 'laboratoryAcademicHours',
startWeekField: 'laboratoryStartWeek',
hoursInputId: 'schedule-rule-laboratory-hours',
startWeekInputId: 'schedule-rule-laboratory-start-week'
},
{
key: 'practice',
label: 'Практики',
hoursField: 'practiceAcademicHours',
startWeekField: 'practiceStartWeek',
hoursInputId: 'schedule-rule-practice-hours',
startWeekInputId: 'schedule-rule-practice-start-week'
}
];
export async function initSchedule() {
const ruleForm = document.getElementById('schedule-rule-form');
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
const ruleIdInput = document.getElementById('schedule-rule-id');
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
hours: document.getElementById(config.hoursInputId),
startWeek: document.getElementById(config.startWeekInputId)
}]));
const ruleGroupsBox = document.getElementById('schedule-rule-groups-box');
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
const ruleResetButton = document.getElementById('schedule-rule-reset');
const slotAddButton = document.getElementById('schedule-slot-add');
const rulesTbody = document.getElementById('schedule-tbody');
const refreshRulesButton = document.getElementById('schedule-refresh');
const visualToggleButton = document.getElementById('schedule-visual-toggle');
const visualCloseButton = document.getElementById('schedule-visual-close');
const visualDrawer = document.getElementById('schedule-visual-drawer');
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');
const conflictModal = document.getElementById('schedule-conflict-modal');
const conflictForm = document.getElementById('schedule-conflict-form');
const conflictCloseButton = document.getElementById('schedule-conflict-close');
const conflictCancelButton = document.getElementById('schedule-conflict-cancel');
const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
const conflictSummary = document.getElementById('schedule-conflict-summary');
const conflictReasons = document.getElementById('schedule-conflict-reasons');
const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
const conflictExistingRuleMeta = document.getElementById('schedule-conflict-existing-rule-meta');
const visualContextMenu = document.getElementById('schedule-visual-context-menu');
const moveSlotModal = document.getElementById('schedule-move-slot-modal');
const moveSlotForm = document.getElementById('schedule-move-slot-form');
const moveSlotCloseButton = document.getElementById('schedule-move-slot-close');
const moveSlotCancelButton = document.getElementById('schedule-move-slot-cancel');
const moveSlotSubmitButton = document.getElementById('schedule-move-slot-submit');
const moveSlotTitle = document.getElementById('schedule-move-slot-title');
const moveSlotSummary = document.getElementById('schedule-move-slot-summary');
const moveSlotDaySelect = document.getElementById('schedule-move-slot-day');
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
let rules = [];
let subjects = [];
let groups = [];
let teachers = [];
let classrooms = [];
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;
let pendingConflictResolution = null;
let visualContextTarget = null;
let pendingSlotMove = null;
bindEvents();
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
renderRuleGroups([]);
renderRuleSlots([{}]);
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
}
function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules);
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
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);
conflictCloseButton?.addEventListener('click', closeConflictModal);
conflictCancelButton?.addEventListener('click', closeConflictModal);
conflictModal?.addEventListener('click', (event) => {
if (event.target === conflictModal) closeConflictModal();
});
conflictForm?.addEventListener('submit', resolveScheduleRuleConflict);
visualContextMenu?.addEventListener('click', handleVisualContextMenuClick);
moveSlotCloseButton?.addEventListener('click', closeMoveSlotModal);
moveSlotCancelButton?.addEventListener('click', closeMoveSlotModal);
moveSlotModal?.addEventListener('click', (event) => {
if (event.target === moveSlotModal) closeMoveSlotModal();
});
moveSlotForm?.addEventListener('submit', saveVisualSlotMove);
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(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') return;
if (moveSlotModal?.classList.contains('open')) {
closeMoveSlotModal();
return;
}
if (conflictModal?.classList.contains('open')) {
closeConflictModal();
return;
}
if (visualContextMenu && !visualContextMenu.hidden) {
closeVisualContextMenu();
return;
}
closeVisualDrawer();
});
document.addEventListener('click', (event) => {
if (visualContextMenu?.hidden) return;
if (event.target.closest('#schedule-visual-context-menu') || event.target.closest('.schedule-visual-edit-rule')) return;
closeVisualContextMenu();
});
ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove');
const subgroupBox = event.target.closest('.slot-subgroup-box');
const subgroupMenu = event.target.closest('.slot-subgroup-menu');
if (removeButton) {
const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]);
return;
}
if (subgroupBox) {
toggleSlotSubgroupMenu(event, subgroupBox);
return;
}
if (subgroupMenu) {
event.stopPropagation();
}
});
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
ruleGroupCheckboxes.addEventListener('change', () => {
updateRuleGroupsText();
renderRuleSlots(readRuleSlots(false));
});
document.addEventListener('click', () => {
closeRuleGroupsMenu();
closeSlotSubgroupMenus();
});
ruleSlotsContainer.addEventListener('change', (event) => {
if (event.target.closest('.slot-lesson-type')) {
renderRuleSlots(readRuleSlots(false));
return;
}
if (event.target.closest('.slot-subgroup-checkboxes')) {
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
}
});
ruleForm.addEventListener('submit', saveRule);
rulesTbody.addEventListener('click', handleRuleTableClick);
}
async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
api.get('/api/subjects'),
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/lesson-types'),
api.get('/api/subgroups')
]);
populateSubjectSelects();
}
async function loadYears() {
academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects();
selectDefaultVisualPeriod();
}
async function loadRules() {
rulesTbody.innerHTML = '
Загрузка... ';
try {
rules = await api.get('/api/admin/schedule-rules');
if (!visualPeriodTouched) {
selectDefaultVisualPeriod();
}
renderRules();
renderVisualSchedule();
} catch (error) {
rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)} `;
setVisualState('Ошибка загрузки правил расписания');
}
}
function renderRules() {
if (!rules.length) {
rulesTbody.innerHTML = 'Правила расписания не созданы ';
renderVisualSchedule();
return;
}
rulesTbody.innerHTML = rules.map(rule => `
${rule.id}
${escapeHtml(rule.subjectName || '-')}
${escapeHtml(formatSemester(rule))}
${escapeHtml((rule.groupNames || []).join(', ') || '-')}
${renderRuleLoadSummary(rule)}
${renderRuleSlotsSummary(rule.slots)}
Изменить
Удалить
`).join('');
}
function toggleVisualDrawer() {
if (!visualDrawer) return;
if (visualDrawer.classList.contains('open')) {
closeVisualDrawer();
} else {
openVisualDrawer();
}
}
function openVisualDrawer() {
if (!visualDrawer) return;
visualDrawer.classList.add('open');
visualDrawer.setAttribute('aria-hidden', 'false');
visualToggleButton?.classList.add('open');
visualToggleButton?.setAttribute('aria-expanded', 'true');
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
if (visualBackdrop) visualBackdrop.hidden = false;
}
function closeVisualDrawer() {
if (!visualDrawer) return;
visualDrawer.classList.remove('open');
visualDrawer.setAttribute('aria-hidden', 'true');
visualToggleButton?.classList.remove('open');
visualToggleButton?.setAttribute('aria-expanded', 'false');
visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
if (visualBackdrop) visualBackdrop.hidden = true;
}
function renderVisualSchedule() {
if (!visualStage || !visualSummary) return;
if (!selectedVisualSemesterId) {
syncVisualGroupFilter([]);
visualSummary.textContent = 'Выберите учебный год и семестр';
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
return;
}
if (!rules.length) {
syncVisualGroupFilter([]);
visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return;
}
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(visualRules);
if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий');
return;
}
if (!columns.length) {
visualSummary.textContent = 'Выберите группы для просмотра';
setVisualState('Отметьте одну или несколько групп в фильтре');
return;
}
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) {
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
return;
}
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
visualStage.innerHTML = `
День
Время
${columns.map(group => `${escapeHtml(group.name)} `).join('')}
${rows.map(renderVisualRow).join('')}
`;
}
function buildVisualRows(columns, slots, lessonsByCell) {
const rows = [];
DAY_OPTIONS.forEach(day => {
const dayRows = slots
.map(slot => ({
day,
slot,
cells: columns.map(group => ({
group,
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
}))
}))
.filter(row => row.cells.some(cell => cell.lessons.length));
dayRows.forEach((row, index) => {
rows.push({
...row,
dayRowspan: index === 0 ? dayRows.length : 0
});
});
});
return rows;
}
function renderVisualRow(row) {
return `
${row.dayRowspan ? `${escapeHtml(row.day.label)} ` : ''}
${escapeHtml(row.slot.orderLabel)}
${escapeHtml(row.slot.timeLabel)}
${row.cells.map(cell => {
const emptyClass = cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty';
return `${renderVisualCellContent(cell.lessons)} `;
}).join('')}
`;
}
function buildVisualLessonsByCell(allowedGroupIds, visualRules) {
const grouped = new Map();
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
visualRules.forEach(rule => {
(rule.slots || []).forEach((slot, index) => {
const slotKey = visualRuleSlotKey(slot, index);
(rule.groupIds || []).forEach((groupId, groupIndex) => {
if (!allowedGroupIds.has(String(groupId))) return;
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
if (!weeks.length) return;
const lesson = {
id: `${rule.id}:${slotKey}:${groupId}`,
ruleId: rule.id,
slotId: slot.id || '',
slotIndex: index,
dayOfWeek: slot.dayOfWeek,
timeSlotId: slot.timeSlotId,
parity: slot.parity || 'BOTH',
subjectName: rule.subjectName || 'Без дисциплины',
teacherName: slot.teacherName || '',
classroomName: slot.classroomName || '',
lessonTypeName: slot.lessonTypeName || '',
lessonFormat: slot.lessonFormat || '',
subgroupNames: slotSubgroupNames(slot),
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
};
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
});
});
grouped.forEach(items => items.sort(compareVisualLessons));
return grouped;
}
function buildActiveWeeksByRuleSlot(visualRules) {
const activeWeeks = new Map();
visualRules.forEach(rule => {
const maxWeeks = maxWeeksForRule(rule);
(rule.groupIds || []).forEach(groupId => {
LESSON_TYPE_LIMITS.forEach(config => {
const totalHours = Number(rule[config.hoursField] || 0);
if (totalHours <= 0) return;
const categorySlots = (rule.slots || [])
.map((slot, index) => ({ slot, index }))
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId) === config.key)
.sort(compareSlotItems);
if (!categorySlots.length) return;
let consumedHours = 0;
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
for (const item of categorySlots) {
if (consumedHours >= totalHours) break;
if (!slotAppliesToWeek(item.slot, week)) continue;
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
activeWeeks.get(key).push(week);
consumedHours += ACADEMIC_HOURS_PER_SLOT;
}
}
});
});
});
return activeWeeks;
}
function renderVisualCellContent(lessons) {
if (!lessons.length) return '';
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
const shouldSplit = lessons.some(l => l.parity !== 'BOTH');
if (!shouldSplit) {
return `${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}
`;
}
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const parityLessons = parity === 'ODD' ? oddLessons : evenLessons;
return `
${escapeHtml(label)}
${parityLessons.length ? parityLessons.map(renderVisualLesson).join('') : ''}
`;
}).join('');
}
function uniqueVisualLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
unique.set(visualLessonSignature([lesson]), lesson);
});
return Array.from(unique.values()).sort(compareVisualLessons);
}
function renderVisualLesson(lesson) {
const meta = [
lesson.lessonTypeName,
lesson.lessonFormat,
lesson.teacherName,
lesson.classroomName
].filter(Boolean).join(' · ');
const subgroupText = lesson.subgroupNames.join(', ');
return `
${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` ${escapeHtml(lesson.weekText)} ` : ''}
${subgroupText ? `${escapeHtml(subgroupText)} ` : ''}
${meta ? `${escapeHtml(meta)} ` : ''}
⋮
`;
}
function collectVisualGroups(visualRules = rules) {
const byId = new Map();
visualRules.forEach(rule => {
(rule.groupIds || []).forEach((id, index) => {
if (!byId.has(String(id))) {
byId.set(String(id), {
id,
name: (rule.groupNames || [])[index] || groupNameById(id)
});
}
});
});
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
}
function collectVisualSlotsFromRules(visualRules = rules) {
const byKey = new Map();
visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
const key = visualSlotKey(slot);
if (!byKey.has(key)) {
byKey.set(key, {
key,
orderNumber: slot.timeSlotOrder,
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
timeLabel: slot.timeSlotLabel || 'Время не указано'
});
}
});
return Array.from(byKey.values()).sort(compareVisualSlots);
}
function visualSlotFromTimeSlot(slot) {
return {
key: String(slot.id),
orderNumber: slot.orderNumber,
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
};
}
function setVisualState(message) {
if (!visualStage) return;
visualStage.innerHTML = `${escapeHtml(message)}
`;
}
function handleVisualGroupFilterChange(event) {
if (!event.target.matches('input[type="checkbox"]')) return;
selectedVisualGroupIds = new Set(Array.from(visualGroupFilter.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => String(input.value)));
visualGroupFilterTouched = true;
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;
event.stopPropagation();
openVisualContextMenu(editButton);
}
function openVisualContextMenu(button) {
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
}
visualContextTarget = {
ruleId: button.dataset.ruleId,
slotId: button.dataset.slotId || '',
slotIndex: button.dataset.slotIndex || ''
};
if (!visualContextMenu) return;
const rect = button.getBoundingClientRect();
visualContextMenu.hidden = false;
const menuRect = visualContextMenu.getBoundingClientRect();
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
visualContextMenu.style.top = `${Math.max(12, top)}px`;
visualContextMenu.style.left = `${Math.max(12, left)}px`;
visualContextMenu.querySelector('button')?.focus();
}
function closeVisualContextMenu() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenu.style.top = '';
visualContextMenu.style.left = '';
}
async function handleVisualContextMenuClick(event) {
const actionButton = event.target.closest('button[data-action]');
if (!actionButton || !visualContextTarget) return;
const action = actionButton.dataset.action;
const target = { ...visualContextTarget };
const rule = rules.find(item => String(item.id) === String(target.ruleId));
if (!rule) {
closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
}
if (action === 'edit-rule') {
closeVisualContextMenu();
fillRuleForm(rule);
closeVisualDrawer();
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
return;
}
if (action === 'move-slot') {
openMoveSlotModal(target);
return;
}
if (action === 'delete-rule') {
await deleteRuleFromVisual(rule);
}
}
async function deleteRuleFromVisual(rule) {
closeVisualContextMenu();
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
try {
await api.delete('/api/admin/schedule-rules/' + rule.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
}
}
function openMoveSlotModal(target) {
const rule = rules.find(item => String(item.id) === String(target.ruleId));
const slotIndex = findRuleSlotIndex(rule, target);
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
if (!rule || !slot) {
closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Слот правила не найден. Обновите список правил.', 'error');
return;
}
pendingSlotMove = {
ruleId: String(rule.id),
slotIndex
};
if (moveSlotTitle) {
moveSlotTitle.textContent = rule.subjectName || 'Изменить день и пару';
}
if (moveSlotSummary) {
moveSlotSummary.textContent = `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`;
}
if (moveSlotDaySelect) {
moveSlotDaySelect.innerHTML = options(DAY_OPTIONS, slot.dayOfWeek);
}
if (moveSlotTimeSelect) {
moveSlotTimeSelect.innerHTML = options(timeSlots.map(toTimeSlotOption), slot.timeSlotId);
}
hideAlert('schedule-move-slot-alert');
closeVisualContextMenu();
moveSlotModal?.classList.add('open');
moveSlotModal?.setAttribute('aria-hidden', 'false');
moveSlotDaySelect?.focus();
}
function closeMoveSlotModal() {
pendingSlotMove = null;
moveSlotModal?.classList.remove('open');
moveSlotModal?.setAttribute('aria-hidden', 'true');
hideAlert('schedule-move-slot-alert');
}
async function saveVisualSlotMove(event) {
event.preventDefault();
if (!pendingSlotMove) return;
hideAlert('schedule-move-slot-alert');
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
if (!rule || !slot) {
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
return;
}
const dayOfWeek = Number(moveSlotDaySelect?.value);
const timeSlotId = Number(moveSlotTimeSelect?.value);
if (!dayOfWeek || !timeSlotId) {
showAlert('schedule-move-slot-alert', 'Выберите день и пару', 'error');
return;
}
const previousButtonText = moveSlotSubmitButton?.textContent || '';
if (moveSlotSubmitButton) {
moveSlotSubmitButton.disabled = true;
moveSlotSubmitButton.textContent = 'Сохраняем...';
}
try {
const slots = (rule.slots || []).map(ruleSlotToPayload);
slots[pendingSlotMove.slotIndex] = {
...slots[pendingSlotMove.slotIndex],
dayOfWeek,
timeSlotId
};
const payload = buildRulePayloadFromRule(rule, slots);
const saved = await api.put('/api/admin/schedule-rules/' + rule.id, payload);
closeMoveSlotModal();
showAlert('schedule-rule-alert', `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, 'success');
await loadRules();
} catch (error) {
if (isScheduleRuleConflictError(error)) {
showAlert(
'schedule-move-slot-alert',
`Новое место занято правилом "${ruleTitle(error.data.conflictRule)}". Выберите другой день или пару.`,
'error'
);
return;
}
showAlert('schedule-move-slot-alert', error.message || 'Ошибка переноса слота', 'error');
} finally {
if (moveSlotSubmitButton) {
moveSlotSubmitButton.disabled = false;
moveSlotSubmitButton.textContent = previousButtonText || 'Сохранить перенос';
}
}
}
function findRuleSlotIndex(rule, target) {
if (!rule?.slots?.length) return -1;
if (target.slotId) {
const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId));
if (byId >= 0) return byId;
}
const index = Number(target.slotIndex);
return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1;
}
function ruleSlotToPayload(slot) {
return {
id: slot.id || null,
dayOfWeek: Number(slot.dayOfWeek),
parity: slot.parity || 'BOTH',
timeSlotId: Number(slot.timeSlotId),
subgroupId: slot.subgroupId || null,
subgroupIds: slotSubgroupIds(slot),
teacherId: Number(slot.teacherId),
classroomId: Number(slot.classroomId),
lessonTypeId: Number(slot.lessonTypeId),
lessonFormat: slot.lessonFormat || 'Очно'
};
}
function syncVisualGroupFilter(allGroups) {
ensureVisualGroupSelection(allGroups);
if (!visualGroupFilter) return;
if (!allGroups.length) {
visualGroupFilter.innerHTML = 'Нет групп в правилах ';
return;
}
visualGroupFilter.innerHTML = allGroups.map(group => {
const id = String(group.id);
return `
${escapeHtml(group.name)}
`;
}).join('');
}
function ensureVisualGroupSelection(allGroups) {
const availableIds = new Set(allGroups.map(group => String(group.id)));
selectedVisualGroupIds = new Set(Array.from(selectedVisualGroupIds).filter(id => availableIds.has(id)));
if (visualGroupFilterTouched) return;
const formIds = selectedRuleGroupIds()
.map(String)
.filter(id => availableIds.has(id));
if (!visualGroupFilterTouched && formIds.length) {
selectedVisualGroupIds = new Set(formIds);
return;
}
if (!selectedVisualGroupIds.size && allGroups.length) {
selectedVisualGroupIds = new Set([String(allGroups[0].id)]);
}
}
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 = 'Выберите учебный год ' +
academicYears.map(year => `${escapeHtml(year.title)} `).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
? 'Выберите семестр ' + yearSemesters
.map(semester => `${escapeHtml(shortSemesterLabel(semester))} `)
.join('')
: 'Нет семестров ';
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 'Нет слотов ';
return slots.map(slot => {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
const parts = [
slot.dayName,
slot.timeSlotLabel,
parity,
slotSubgroupNames(slot).join(', '),
slot.teacherName,
slot.classroomName,
slot.lessonTypeName,
slot.lessonFormat
].filter(Boolean);
return `${escapeHtml(parts.join(' · '))}
`;
}).join('');
}
function renderRuleLoadSummary(rule) {
const rows = LESSON_TYPE_LIMITS
.map(config => ({
label: config.label,
hours: Number(rule[config.hoursField] || 0),
startWeek: Number(rule[config.startWeekField] || 1)
}))
.filter(item => item.hours > 0);
if (!rows.length) return 'Часы не указаны ';
return rows.map(item => (
`${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}
`
)).join('');
}
async function handleRuleTableClick(event) {
const editButton = event.target.closest('.btn-edit-rule');
const deleteButton = event.target.closest('.btn-delete-rule');
if (editButton) {
const rule = rules.find(item => item.id == editButton.dataset.id)
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
fillRuleForm(rule);
return;
}
if (deleteButton) {
if (!confirm('Удалить правило расписания?')) return;
try {
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
}
}
}
async function saveRule(event) {
event.preventDefault();
hideAlert('schedule-rule-alert');
let payload = null;
let id = '';
try {
payload = buildRulePayload();
id = ruleIdInput.value;
const saved = await saveRulePayload(id, payload);
await finishSuccessfulRuleSave(saved);
} catch (error) {
if (payload && isScheduleRuleConflictError(error)) {
openConflictModal(error, payload, id);
return;
}
showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
}
}
async function saveRulePayload(id, payload) {
return id
? api.put('/api/admin/schedule-rules/' + id, payload)
: api.post('/api/admin/schedule-rules', payload);
}
async function finishSuccessfulRuleSave(saved) {
resetRuleForm();
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
await loadRules();
}
function openConflictModal(error, pendingPayload, pendingRuleId) {
const conflictRule = error.data.conflictRule;
pendingConflictResolution = {
conflictRule: clonePlain(conflictRule),
conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [],
pendingPayload: clonePlain(pendingPayload),
pendingRuleId: pendingRuleId || ''
};
if (conflictSummary) {
conflictSummary.textContent = error.message || 'Ранее созданное правило занимает выбранный слот.';
}
if (conflictNewRuleTitle) {
conflictNewRuleTitle.textContent = ruleDraftTitle(pendingPayload);
}
if (conflictNewRuleMeta) {
conflictNewRuleMeta.textContent = ruleDraftMeta(pendingPayload);
}
if (conflictExistingRuleTitle) {
conflictExistingRuleTitle.textContent = ruleTitle(conflictRule);
}
if (conflictExistingRuleMeta) {
conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
}
renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons);
renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields);
hideAlert('schedule-conflict-alert');
conflictModal?.classList.add('open');
conflictModal?.setAttribute('aria-hidden', 'false');
}
function closeConflictModal() {
pendingConflictResolution = null;
conflictModal?.classList.remove('open');
conflictModal?.setAttribute('aria-hidden', 'true');
if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
if (conflictReasons) {
conflictReasons.hidden = true;
conflictReasons.innerHTML = '';
}
hideAlert('schedule-conflict-alert');
}
async function resolveScheduleRuleConflict(event) {
event.preventDefault();
if (!pendingConflictResolution) return;
hideAlert('schedule-conflict-alert');
const previousButtonText = conflictSubmitButton?.textContent || '';
if (conflictSubmitButton) {
conflictSubmitButton.disabled = true;
conflictSubmitButton.textContent = 'Сохраняем...';
}
try {
const currentResolution = pendingConflictResolution;
const conflictPayload = buildRulePayloadFromRule(
currentResolution.conflictRule,
readConflictSlots(true)
);
await api.put('/api/admin/schedule-rules/' + currentResolution.conflictRule.id, conflictPayload);
try {
const saved = await saveRulePayload(
currentResolution.pendingRuleId,
currentResolution.pendingPayload
);
closeConflictModal();
await finishSuccessfulRuleSave(saved);
} catch (retryError) {
if (isScheduleRuleConflictError(retryError)) {
await loadRules();
openConflictModal(
retryError,
currentResolution.pendingPayload,
currentResolution.pendingRuleId
);
showAlert(
'schedule-conflict-alert',
'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.',
'error'
);
return;
}
throw retryError;
}
} catch (error) {
if (isScheduleRuleConflictError(error)) {
showAlert(
'schedule-conflict-alert',
`Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule)}. Выберите другой день, пару, преподавателя или аудиторию.`,
'error'
);
return;
}
showAlert('schedule-conflict-alert', error.message || 'Ошибка разрешения конфликта', 'error');
} finally {
if (conflictSubmitButton) {
conflictSubmitButton.disabled = false;
conflictSubmitButton.textContent = previousButtonText || 'Сохранить и повторить';
}
}
}
function renderConflictReasons(fields, reasons) {
if (!conflictReasons) return;
const labels = Array.isArray(reasons) && reasons.length
? reasons
: (fields || []).map(conflictFieldLabel).filter(Boolean);
if (!labels.length) {
conflictReasons.hidden = true;
conflictReasons.innerHTML = '';
return;
}
conflictReasons.hidden = false;
conflictReasons.innerHTML = `
Причина:
${labels.map(label => `${escapeHtml(label)} `).join('')}
`;
}
function renderConflictSlots(rule, conflictFields = []) {
const slots = rule.slots || [];
if (!conflictSlotsContainer) return;
if (!slots.length) {
conflictSlotsContainer.innerHTML = 'У конфликтующего правила нет слотов
';
return;
}
conflictSlotsContainer.innerHTML = slots.map((slot, index) => {
const fixedParts = [
slot.lessonTypeName,
slot.lessonFormat,
slotSubgroupNames(slot).join(', ')
].filter(Boolean);
const groupHitClass = conflictFieldClass('group', conflictFields);
return `
Слот ${index + 1}
${escapeHtml(fixedParts.join(' · ') || 'Занятие')}
${escapeHtml(conflictSlotCurrentText(slot))}
День ${options(DAY_OPTIONS, slot.dayOfWeek)}
Чётность ${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}
Пара ${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}
Преподаватель ${options(teachers.map(toTeacherOption), slot.teacherId)}
Аудитория ${options(classrooms.map(toClassroomOption), slot.classroomId)}
`;
}).join('');
}
function conflictFieldClass(field, fields) {
return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
}
function conflictFieldLabel(field) {
if (field === 'teacher') return 'Преподаватель';
if (field === 'classroom') return 'Аудитория';
if (field === 'group') return 'Группа';
return field;
}
function readConflictSlots(validate) {
const conflictRule = pendingConflictResolution?.conflictRule;
const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
return rows.map((row, index) => {
const original = conflictRule?.slots?.[index] || {};
const slot = {
id: original.id || null,
dayOfWeek: Number(row.querySelector('.conflict-slot-day').value),
parity: row.querySelector('.conflict-slot-parity').value,
timeSlotId: Number(row.querySelector('.conflict-slot-time').value),
subgroupId: original.subgroupId || null,
subgroupIds: slotSubgroupIds(original),
teacherId: Number(row.querySelector('.conflict-slot-teacher').value),
classroomId: Number(row.querySelector('.conflict-slot-classroom').value),
lessonTypeId: Number(original.lessonTypeId),
lessonFormat: original.lessonFormat || 'Очно'
};
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1} конфликтующего правила`);
}
return slot;
});
}
function buildRulePayloadFromRule(rule, slots) {
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
return {
id: Number(rule.id),
subjectId: Number(rule.subjectId),
semesterId: Number(rule.semesterId),
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
lectureStartWeek: Number(rule.lectureStartWeek || 1),
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
practiceStartWeek: Number(rule.practiceStartWeek || 1),
groupIds,
slots
};
}
function isScheduleRuleConflictError(error) {
return error?.status === 409 && Boolean(error.data?.conflictRule);
}
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
function ruleDraftTitle(payload) {
const subject = subjects.find(item => String(item.id) === String(payload.subjectId));
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
}
function ruleTitle(rule) {
return rule?.subjectName || ruleDraftTitle(rule || {});
}
function ruleDraftMeta(payload) {
const groupNames = (payload.groupIds || []).map(groupNameById).join(', ') || 'группы не выбраны';
return [semesterTextById(payload.semesterId), groupNames, typeLoadText(payload)]
.filter(Boolean)
.join(' · ');
}
function ruleMeta(rule) {
const groupNames = (rule.groupNames || []).join(', ')
|| (rule.groupIds || []).map(groupNameById).join(', ')
|| 'группы не выбраны';
return [formatSemester(rule), groupNames, typeLoadText(rule)]
.filter(Boolean)
.join(' · ');
}
function typeLoadText(rule) {
return LESSON_TYPE_LIMITS
.map(config => {
const hours = Number(rule?.[config.hoursField] || 0);
const startWeek = Number(rule?.[config.startWeekField] || 1);
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
})
.filter(Boolean)
.join('; ');
}
function semesterTextById(semesterId) {
const semester = semesterById(semesterId);
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
}
function conflictSlotCurrentText(slot) {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
return [
slot.dayName,
slot.timeSlotLabel,
parity,
slot.teacherName,
slot.classroomName
].filter(Boolean).join(' · ');
}
function buildRulePayload() {
const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
const typeLimits = readTypeLimits();
validateTypeLimits(typeLimits, slots);
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
return {
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
subjectId: Number(ruleSubjectSelect.value),
semesterId: Number(ruleSemesterSelect.value),
...typeLimits,
groupIds,
slots
};
}
function fillRuleForm(rule) {
ruleFormTitle.textContent = 'Редактирование правила расписания';
ruleIdInput.value = rule.id;
ruleSubjectSelect.value = rule.subjectId || '';
ruleSemesterSelect.value = rule.semesterId || '';
fillTypeLimits(rule);
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
renderRuleGroups(rule.groupIds || []);
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
hideAlert('schedule-rule-alert');
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function resetRuleForm() {
ruleForm.reset();
ruleFormTitle.textContent = 'Новое правило расписания';
ruleIdInput.value = '';
fillTypeLimits({});
renderRuleGroups([]);
renderRuleSlots([{}]);
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert');
}
function renderRuleGroups(selectedIds) {
if (!groups.length) {
ruleGroupCheckboxes.innerHTML = 'Нет групп
';
updateRuleGroupsText();
return;
}
const selected = new Set((selectedIds || []).map(String));
ruleGroupCheckboxes.innerHTML = groups.map(group => `
${escapeHtml(group.name)}
`).join('');
updateRuleGroupsText();
}
function renderRuleSlots(slots) {
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
День ${options(DAY_OPTIONS, slot.dayOfWeek)}
Чётность ${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}
Пара ${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}
Преподаватель ${options(teachers.map(toTeacherOption), slot.teacherId)}
Аудитория ${options(classrooms.map(toClassroomOption), slot.classroomId)}
Тип ${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
Формат ${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}
Удалить
`).join('');
}
function readRuleSlots(validate) {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => {
const subgroupIds = readSlotSubgroupIds(row);
const slot = {
id: null,
dayOfWeek: Number(row.querySelector('.slot-day').value),
parity: row.querySelector('.slot-parity').value,
timeSlotId: Number(row.querySelector('.slot-time').value),
subgroupId: subgroupIds[0] || null,
subgroupIds,
teacherId: Number(row.querySelector('.slot-teacher').value),
classroomId: Number(row.querySelector('.slot-classroom').value),
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
lessonFormat: row.querySelector('.slot-format').value
};
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1}`);
}
if (validate) {
validateSlotSubgroup(slot, index);
}
return slot;
});
}
function readTypeLimits() {
return LESSON_TYPE_LIMITS.reduce((payload, config) => {
const inputs = ruleTypeInputs[config.key];
payload[config.hoursField] = Number(inputs.hours.value || 0);
payload[config.startWeekField] = Number(inputs.startWeek.value || 1);
return payload;
}, {});
}
function fillTypeLimits(rule) {
LESSON_TYPE_LIMITS.forEach(config => {
const inputs = ruleTypeInputs[config.key];
inputs.hours.value = rule[config.hoursField] ?? 0;
inputs.startWeek.value = rule[config.startWeekField] ?? 1;
});
}
function validateTypeLimits(typeLimits, slots) {
const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean));
let hasHours = false;
for (const config of LESSON_TYPE_LIMITS) {
const hours = typeLimits[config.hoursField];
const startWeek = typeLimits[config.startWeekField];
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
if (hours > 0) hasHours = true;
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
}
if (hours === 0 && selectedTypeKeys.has(config.key)) {
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
}
}
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
}
function validateSlotSubgroup(slot, index) {
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
const subgroupIds = slotSubgroupIds(slot);
if (subgroupIds.length && typeKey !== 'laboratory') {
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
}
if (!subgroupIds.length) return;
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
const subgroupGroupIds = new Set();
for (const subgroupId of subgroupIds) {
const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId));
if (!selectedSubgroup) {
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
}
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
}
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
}
subgroupGroupIds.add(String(selectedSubgroup.groupId));
}
}
async function loadTimeSlots() {
const slots = await api.get('/api/admin/time-slots');
timeSlots = slots
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
}
function populateSubjectSelects() {
ruleSubjectSelect.innerHTML = 'Выберите дисциплину ' +
subjects.map(subject => `${escapeHtml(subject.name)} `).join('');
syncSelects(ruleSubjectSelect);
}
function populateSemesterSelects() {
const semesterOptions = 'Выберите семестр ' +
semesters.map(semester => `${escapeHtml(semesterLabel(semester))} `).join('');
ruleSemesterSelect.innerHTML = semesterOptions;
syncSelects(ruleSemesterSelect);
}
function options(items, selectedValue) {
const selected = selectedValue == null ? '' : String(selectedValue);
return 'Выберите ' + items.map(item => {
const value = String(item.value);
return `${escapeHtml(item.label)} `;
}).join('');
}
function subgroupOptions(slot) {
if (!isLaboratorySlot(slot)) {
return 'Только для лабораторных ';
}
const selectedGroupIds = selectedRuleGroupIds();
if (selectedGroupIds.length !== 1) {
return 'Выберите группу правила ';
}
const selected = slotSubgroupIds(slot)[0] == null
? WHOLE_GROUP_SUBGROUP_VALUE
: String(slotSubgroupIds(slot)[0]);
const selectedGroups = new Set(selectedGroupIds.map(String));
const matchingSubgroups = subgroups
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
return `Вся группа ` + matchingSubgroups.map(subgroup => {
const value = String(subgroup.id);
return `${escapeHtml(subgroupLabel(subgroup))} `;
}).join('');
}
function renderSubgroupField(slot, index) {
return selectedRuleGroupIds().length > 1
? renderSubgroupMultiSelect(slot, index)
: `Подгруппа ${subgroupOptions(slot)}
`;
}
function renderSubgroupMultiSelect(slot, index) {
const selected = new Set(slotSubgroupIds(slot).map(String));
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
const matchingSubgroups = subgroups
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
const items = matchingSubgroups.length
? matchingSubgroups.map(subgroup => {
const value = String(subgroup.id);
return `
${escapeHtml(subgroupLabel(subgroup))}
`;
}).join('')
: 'Нет подгрупп выбранных групп
';
return `
`;
}
function readSlotSubgroupIds(row) {
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked'))
.map(input => Number(input.value))
.filter(Boolean);
return checkboxValues.length
? checkboxValues
: (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []);
}
function slotSubgroupIds(slot) {
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
return slot.subgroupIds.map(Number).filter(Boolean);
}
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
}
function slotSubgroupNames(slot) {
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
return slot.subgroupNames.filter(Boolean);
}
return slot.subgroupName ? [slot.subgroupName] : [];
}
function slotSubgroupText(slot) {
const selectedIds = slotSubgroupIds(slot);
if (!selectedIds.length) return 'Все выбранные группы';
if (selectedIds.length === 1) {
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
}
return `Выбрано подгрупп: ${selectedIds.length}`;
}
function selectedRuleGroupIds() {
return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => Number(input.value));
}
function toggleRuleGroupsMenu(event) {
event.stopPropagation();
const isOpen = ruleGroupsMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (isOpen) {
closeRuleGroupsMenu();
return;
}
ruleGroupsMenu.classList.add('open');
ruleGroupsBox.classList.add('active');
ruleGroupsBox.setAttribute('aria-expanded', 'true');
}
function handleRuleGroupsKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleRuleGroupsMenu(event);
}
if (event.key === 'Escape') {
closeRuleGroupsMenu();
}
}
function closeRuleGroupsMenu() {
ruleGroupsMenu.classList.remove('open');
ruleGroupsBox.classList.remove('active');
ruleGroupsBox.setAttribute('aria-expanded', 'false');
}
function toggleSlotSubgroupMenu(event, box) {
event.stopPropagation();
const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu');
if (!menu) return;
const isOpen = menu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active'));
if (isOpen) {
box.setAttribute('aria-expanded', 'false');
return;
}
menu.classList.add('open');
box.classList.add('active');
box.setAttribute('aria-expanded', 'true');
}
function closeSlotSubgroupMenus() {
ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open'));
ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => {
box.classList.remove('active');
box.setAttribute('aria-expanded', 'false');
});
}
function updateSlotSubgroupText(row) {
if (!row) return;
const text = row.querySelector('.slot-subgroup-box .select-text');
if (!text) return;
const selectedIds = readSlotSubgroupIds(row);
if (!selectedIds.length) {
text.textContent = 'Все выбранные группы';
return;
}
if (selectedIds.length === 1) {
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
return;
}
text.textContent = `Выбрано подгрупп: ${selectedIds.length}`;
}
function updateRuleGroupsText() {
const selectedIds = selectedRuleGroupIds();
if (!groups.length) {
ruleGroupsText.textContent = 'Нет групп';
return;
}
if (!selectedIds.length) {
ruleGroupsText.textContent = 'Выберите группы...';
return;
}
if (selectedIds.length === 1) {
const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0]));
ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа';
return;
}
ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`;
}
function toTimeSlotOption(slot) {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
}
function toTeacherOption(teacher) {
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
}
function toClassroomOption(classroom) {
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
return { value: classroom.id, label: `${classroom.name}${capacity}` };
}
function toLessonTypeOption(lessonType) {
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;
}
function isLaboratorySlot(slot) {
return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory';
}
function subgroupLabel(subgroup) {
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
}
function lessonTypeKey(lessonType) {
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
if (name.includes('лекц')) return 'lecture';
if (name.includes('лаб')) return 'laboratory';
if (name.includes('практ')) return 'practice';
return null;
}
function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
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 || '-'}`;
}
function normalizeSemesterType(type) {
return String(type || '').toLowerCase();
}
function trimTime(value) {
if (!value) return '';
return String(value).slice(0, 5);
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function visualCellKey(groupId, dayOfWeek, slotKey) {
return `${groupId}:${dayOfWeek}:${slotKey}`;
}
function visualSlotKey(slot) {
return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
}
function visualRuleSlotKey(slot, index) {
return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
}
function activeWeekKey(ruleId, groupId, slotKey) {
return `${ruleId}:${groupId}:${slotKey}`;
}
function slotAppliesToWeek(slot, week) {
if (!slot.parity || slot.parity === 'BOTH') return true;
const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
return slot.parity === weekParity;
}
function maxWeeksForRule(rule) {
const semester = semesters.find(item => String(item.id) === String(rule.semesterId));
if (semester?.startDate && semester?.endDate) {
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));
}
return 18;
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function compactWeekRanges(weeks, maxWeeks, parity) {
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
if (!uniqueWeeks.length) return '';
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} нед.)`;
}
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) {
return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
|| compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
|| Number(first.index || 0) - Number(second.index || 0);
}
function slotDescriptor(slot) {
return {
key: visualSlotKey(slot),
orderNumber: slot.timeSlotOrder,
timeLabel: slot.timeSlotLabel
};
}
function compareVisualSlots(first, second) {
const firstOrder = Number(first.orderNumber || first.key);
const secondOrder = Number(second.orderNumber || second.key);
if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
return firstOrder - secondOrder;
}
return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
}
function compareVisualLessons(first, second) {
return naturalCompare(first.subjectName, second.subjectName)
|| naturalCompare(first.weekText, second.weekText)
|| naturalCompare(first.lessonTypeName, second.lessonTypeName)
|| naturalCompare(first.teacherName, second.teacherName);
}
function visualLessonSignature(lessons) {
return lessons
.map(lesson => [
lesson.id,
lesson.subjectName,
lesson.weekText,
lesson.lessonTypeName,
lesson.teacherName,
lesson.classroomName,
lesson.subgroupNames.join(',')
].join(':'))
.sort((a, b) => naturalCompare(a, b))
.join('|');
}
function groupNameById(id) {
return groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
function ruleCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'правил';
if (count % 10 === 1) return 'правило';
if ([2, 3, 4].includes(count % 10)) return 'правила';
return 'правил';
}
function groupCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'групп';
if (count % 10 === 1) return 'группа';
if ([2, 3, 4].includes(count % 10)) return 'группы';
return 'групп';
}
function rowCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'строк';
if (count % 10 === 1) return 'строка';
if ([2, 3, 4].includes(count % 10)) return 'строки';
return 'строк';
}
}