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 SEMESTER_LABELS = { autumn: 'осенний', spring: 'весенний' }; const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; 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'); let rules = []; let subjects = []; let groups = []; let teachers = []; let classrooms = []; let lessonTypes = []; let subgroups = []; let timeSlots = []; let semesters = []; 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), {}])); 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() { const academicYears = await api.get('/api/admin/calendar/years'); semesters = academicYears.flatMap(year => year.semesters || []); populateSemesterSelects(); } async function loadRules() { rulesTbody.innerHTML = 'Загрузка...'; try { rules = await api.get('/api/admin/schedule-rules'); renderRules(); } catch (error) { rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function renderRules() { if (!rules.length) { rulesTbody.innerHTML = 'Правила расписания не созданы'; 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 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'); try { const payload = buildRulePayload(); const id = ruleIdInput.value; const saved = id ? await api.put('/api/admin/schedule-rules/' + id, payload) : await api.post('/api/admin/schedule-rules', payload); showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success'); resetRuleForm(); await loadRules(); } catch (error) { showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error'); } } 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 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 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 })); }); } }