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 => ``).join('')} ${rows.map(renderVisualRow).join('')}
День Время${escapeHtml(group.name)}
`; } 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 ` `; }).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 => ``).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 => ``) .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))}
`; }).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 => ` `).join(''); updateRuleGroupsText(); } function renderRuleSlots(slots) { ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
`).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 => ``).join(''); syncSelects(ruleSubjectSelect); } function populateSemesterSelects() { const semesterOptions = '' + semesters.map(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 ``; }).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 ``; }).join(''); } function renderSubgroupField(slot, index) { return selectedRuleGroupIds().length > 1 ? renderSubgroupMultiSelect(slot, index) : `
`; } 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 ` `; }).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 'строк'; } }