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: 'весенний' }; 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 ruleActiveFromInput = document.getElementById('schedule-rule-active-from'); const ruleHoursInput = document.getElementById('schedule-rule-hours'); const ruleGroupsContainer = document.getElementById('schedule-rule-groups'); 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 timeSlotForm = document.getElementById('time-slot-form'); const timeSlotFormTitle = document.getElementById('time-slot-form-title'); const timeSlotIdInput = document.getElementById('time-slot-id'); const timeSlotOrderInput = document.getElementById('time-slot-order'); const timeSlotStartInput = document.getElementById('time-slot-start'); const timeSlotEndInput = document.getElementById('time-slot-end'); const timeSlotDurationInput = document.getElementById('time-slot-duration'); const timeSlotResetButton = document.getElementById('time-slot-reset'); const timeSlotsTbody = document.getElementById('time-slots-tbody'); const refreshSlotsButton = document.getElementById('time-slots-refresh'); const academicYearForm = document.getElementById('academic-year-form'); const academicYearFormTitle = document.getElementById('academic-year-form-title'); const academicYearIdInput = document.getElementById('academic-year-id'); const academicYearTitleInput = document.getElementById('academic-year-title'); const academicYearStartInput = document.getElementById('academic-year-start'); const academicYearEndInput = document.getElementById('academic-year-end'); const academicYearResetButton = document.getElementById('academic-year-reset'); const academicYearsTbody = document.getElementById('academic-years-tbody'); const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); const semesterForm = document.getElementById('semester-form'); const semesterFormTitle = document.getElementById('semester-form-title'); const semesterIdInput = document.getElementById('semester-id'); const semesterYearSelect = document.getElementById('semester-year'); const semesterTypeSelect = document.getElementById('semester-type'); const semesterStartInput = document.getElementById('semester-start'); const semesterEndInput = document.getElementById('semester-end'); const semesterResetButton = document.getElementById('semester-reset'); const calendarForm = document.getElementById('academic-calendar-form'); const calendarFormTitle = document.getElementById('academic-calendar-form-title'); const calendarIdInput = document.getElementById('academic-calendar-id'); const calendarTitleInput = document.getElementById('academic-calendar-title'); const calendarYearSelect = document.getElementById('academic-calendar-year'); const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); const calendarProfileSelect = document.getElementById('academic-calendar-profile'); const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); const calendarResetButton = document.getElementById('academic-calendar-reset'); const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); const calendarsTbody = document.getElementById('academic-calendars-tbody'); const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); const editorLoadButton = document.getElementById('calendar-editor-load'); const editorSaveButton = document.getElementById('calendar-editor-save'); const fillCourseSelect = document.getElementById('calendar-fill-course'); const fillStartInput = document.getElementById('calendar-fill-start'); const fillEndInput = document.getElementById('calendar-fill-end'); const fillActivitySelect = document.getElementById('calendar-fill-activity'); const fillApplyButton = document.getElementById('calendar-fill-apply'); const calendarGrid = document.getElementById('calendar-grid'); const calendarTotals = document.getElementById('calendar-totals'); let rules = []; let subjects = []; let groups = []; let teachers = []; let classrooms = []; let lessonTypes = []; let timeSlots = []; let academicYears = []; let semesters = []; let specialties = []; let profilesBySpecialty = new Map(); let studyForms = []; let activityTypes = []; let calendars = []; let currentGridRows = []; bindEvents(); try { await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]); renderRuleGroups([]); renderRuleSlots([{}]); await Promise.all([loadRules(), loadCalendars()]); } catch (error) { showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); } function bindEvents() { refreshRulesButton?.addEventListener('click', loadRules); refreshSlotsButton?.addEventListener('click', loadTimeSlots); academicYearsRefreshButton?.addEventListener('click', loadYears); calendarsRefreshButton?.addEventListener('click', loadCalendars); ruleResetButton?.addEventListener('click', resetRuleForm); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); timeSlotResetButton?.addEventListener('click', resetTimeSlotForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm); semesterResetButton?.addEventListener('click', resetSemesterForm); calendarResetButton?.addEventListener('click', resetCalendarForm); calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); editorLoadButton?.addEventListener('click', loadCalendarGrid); editorSaveButton?.addEventListener('click', saveCalendarGrid); fillApplyButton?.addEventListener('click', applyCalendarFill); ruleSlotsContainer.addEventListener('click', (event) => { const removeButton = event.target.closest('.schedule-slot-remove'); if (!removeButton) return; const slots = readRuleSlots(false); slots.splice(Number(removeButton.dataset.index), 1); renderRuleSlots(slots.length ? slots : [{}]); }); calendarGrid.addEventListener('change', (event) => { if (event.target.matches('.calendar-day-activity')) { renderCalendarTotals(); } }); ruleForm.addEventListener('submit', saveRule); timeSlotForm.addEventListener('submit', saveTimeSlot); academicYearForm.addEventListener('submit', saveAcademicYear); semesterForm.addEventListener('submit', saveSemester); calendarForm.addEventListener('submit', saveCalendar); rulesTbody.addEventListener('click', handleRuleTableClick); timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick); academicYearsTbody.addEventListener('click', handleAcademicYearTableClick); calendarsTbody.addEventListener('click', handleCalendarTableClick); } async function loadBaseLists() { [subjects, groups, teachers, classrooms, lessonTypes, specialties] = 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/specialties') ]); await loadProfiles(); populateSubjectSelects(); populateSpecialtySelects(); } async function loadProfiles() { const pairs = await Promise.all(specialties.map(async (speciality) => { const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); return [String(speciality.id), profiles]; })); profilesBySpecialty = new Map(pairs); } 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(', ') || '-')} ${escapeHtml(rule.activeFromDate || '-')} ${escapeHtml(String(rule.totalAcademicHours ?? '-'))} ${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, slot.teacherName, slot.classroomName, slot.lessonTypeName, slot.lessonFormat ].filter(Boolean); return `
${escapeHtml(parts.join(' · '))}
`; }).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 = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) .map(input => Number(input.value)); const slots = readRuleSlots(true); if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); if (!ruleActiveFromInput.value) throw new Error('Укажите дату начала правила'); if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) throw new Error('Укажите количество академических часов'); 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), activeFromDate: ruleActiveFromInput.value, totalAcademicHours: Number(ruleHoursInput.value), groupIds, slots }; } function fillRuleForm(rule) { ruleFormTitle.textContent = 'Редактирование правила расписания'; ruleIdInput.value = rule.id; ruleSubjectSelect.value = rule.subjectId || ''; ruleSemesterSelect.value = rule.semesterId || ''; ruleActiveFromInput.value = rule.activeFromDate || ''; ruleHoursInput.value = rule.totalAcademicHours || ''; 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 = ''; renderRuleGroups([]); renderRuleSlots([{}]); syncSelects(ruleSubjectSelect, ruleSemesterSelect); hideAlert('schedule-rule-alert'); } function renderRuleGroups(selectedIds) { if (!groups.length) { ruleGroupsContainer.innerHTML = '
Нет групп
'; return; } const selected = new Set((selectedIds || []).map(String)); ruleGroupsContainer.innerHTML = groups.map(group => ` `).join(''); } function renderRuleSlots(slots) { ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
`).join(''); } function readRuleSlots(validate) { const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); return rows.map((row, index) => { 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: null, 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}`); } return slot; }); } async function loadTimeSlots() { timeSlotsTbody.innerHTML = 'Загрузка...'; try { timeSlots = await api.get('/api/admin/time-slots'); renderTimeSlots(); } catch (error) { timeSlotsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function renderTimeSlots() { if (!timeSlots.length) { timeSlotsTbody.innerHTML = 'Сетка пар не настроена'; return; } timeSlotsTbody.innerHTML = timeSlots.map(slot => ` ${slot.orderNumber} ${escapeHtml(trimTime(slot.startTime))} ${escapeHtml(trimTime(slot.endTime))} ${escapeHtml(String(slot.durationMinutes ?? '-'))} `).join(''); } async function saveTimeSlot(event) { event.preventDefault(); hideAlert('time-slot-alert'); const payload = { id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null, orderNumber: Number(timeSlotOrderInput.value), startTime: timeSlotStartInput.value, endTime: timeSlotEndInput.value, durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null }; try { if (!payload.orderNumber || !payload.startTime || !payload.endTime) { throw new Error('Заполните номер пары и время'); } const id = timeSlotIdInput.value; await (id ? api.put('/api/admin/time-slots/' + id, payload) : api.post('/api/admin/time-slots', payload)); showAlert('time-slot-alert', 'Временной слот сохранён', 'success'); resetTimeSlotForm(); await loadTimeSlots(); } catch (error) { showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error'); } } async function handleTimeSlotTableClick(event) { const editButton = event.target.closest('.btn-edit-time-slot'); const deleteButton = event.target.closest('.btn-delete-time-slot'); if (editButton) { const slot = timeSlots.find(item => item.id == editButton.dataset.id); if (!slot) return; timeSlotFormTitle.textContent = 'Редактирование временного слота'; timeSlotIdInput.value = slot.id; timeSlotOrderInput.value = slot.orderNumber || ''; timeSlotStartInput.value = trimTime(slot.startTime); timeSlotEndInput.value = trimTime(slot.endTime); timeSlotDurationInput.value = slot.durationMinutes || ''; hideAlert('time-slot-alert'); timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (deleteButton) { if (!confirm('Удалить временной слот?')) return; try { await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id); showAlert('time-slot-alert', 'Временной слот удалён', 'success'); await loadTimeSlots(); } catch (error) { showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error'); } } } function resetTimeSlotForm() { timeSlotForm.reset(); timeSlotFormTitle.textContent = 'Новый временной слот'; timeSlotIdInput.value = ''; hideAlert('time-slot-alert'); } async function loadYears() { academicYearsTbody.innerHTML = 'Загрузка...'; try { academicYears = await api.get('/api/admin/calendar/years'); semesters = academicYears.flatMap(year => year.semesters || []); renderAcademicYears(); populateYearSelects(); populateSemesterSelects(); } catch (error) { academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function renderAcademicYears() { if (!academicYears.length) { academicYearsTbody.innerHTML = 'Учебные годы не созданы'; return; } academicYearsTbody.innerHTML = academicYears.map(year => ` ${escapeHtml(year.title)} ${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)} ${renderSemesterList(year.semesters || [])} `).join(''); } function renderSemesterList(items) { if (!items.length) return 'Нет семестров'; return items.map(semester => `
${escapeHtml(semesterLabel(semester))}
`).join(''); } async function saveAcademicYear(event) { event.preventDefault(); hideAlert('academic-year-alert'); const payload = { title: academicYearTitleInput.value.trim(), startDate: academicYearStartInput.value, endDate: academicYearEndInput.value }; try { if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); const id = academicYearIdInput.value; await (id ? api.put('/api/admin/calendar/years/' + id, payload) : api.post('/api/admin/calendar/years', payload)); showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); resetAcademicYearForm(); await loadYears(); await loadCalendars(); } catch (error) { showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); } } async function handleAcademicYearTableClick(event) { const editYearButton = event.target.closest('.btn-edit-year'); const deleteYearButton = event.target.closest('.btn-delete-year'); const editSemesterButton = event.target.closest('.btn-edit-semester'); if (editYearButton) { const year = academicYears.find(item => item.id == editYearButton.dataset.id); if (!year) return; academicYearFormTitle.textContent = 'Редактирование учебного года'; academicYearIdInput.value = year.id; academicYearTitleInput.value = year.title || ''; academicYearStartInput.value = year.startDate || ''; academicYearEndInput.value = year.endDate || ''; hideAlert('academic-year-alert'); academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (editSemesterButton) { const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); if (!semester) return; semesterFormTitle.textContent = 'Редактирование семестра'; semesterIdInput.value = semester.id; semesterYearSelect.value = semester.academicYearId || ''; semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); semesterStartInput.value = semester.startDate || ''; semesterEndInput.value = semester.endDate || ''; syncSelects(semesterYearSelect, semesterTypeSelect); hideAlert('semester-alert'); semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (deleteYearButton) { if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; try { await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); showAlert('academic-year-alert', 'Учебный год удалён', 'success'); await loadYears(); await loadCalendars(); } catch (error) { showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); } } } function resetAcademicYearForm() { academicYearForm.reset(); academicYearFormTitle.textContent = 'Учебный год'; academicYearIdInput.value = ''; hideAlert('academic-year-alert'); } async function saveSemester(event) { event.preventDefault(); hideAlert('semester-alert'); const yearId = semesterYearSelect.value; const payload = { semesterType: semesterTypeSelect.value, startDate: semesterStartInput.value, endDate: semesterEndInput.value }; try { if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) throw new Error('Заполните учебный год, тип и даты семестра'); const id = semesterIdInput.value; await (id ? api.put('/api/admin/calendar/semesters/' + id, payload) : api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload)); showAlert('semester-alert', 'Семестр сохранён', 'success'); resetSemesterForm(); await loadYears(); } catch (error) { showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error'); } } function resetSemesterForm() { semesterForm.reset(); semesterFormTitle.textContent = 'Семестр'; semesterIdInput.value = ''; syncSelects(semesterYearSelect, semesterTypeSelect); hideAlert('semester-alert'); } async function loadStudyForms() { studyForms = await api.get('/api/admin/academic-calendars/study-forms'); populateStudyFormSelect(); } async function loadActivityTypes() { activityTypes = await api.get('/api/admin/calendar/activity-types'); populateActivitySelects(); } async function loadCalendars() { calendarsTbody.innerHTML = 'Загрузка...'; try { calendars = await api.get('/api/admin/academic-calendars'); renderCalendars(); populateEditorCalendarSelect(); } catch (error) { calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function renderCalendars() { if (!calendars.length) { calendarsTbody.innerHTML = 'Календарные графики не созданы'; return; } calendarsTbody.innerHTML = calendars.map(calendar => ` ${escapeHtml(calendar.title)} ${escapeHtml(calendar.academicYearTitle || '-')} ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} ${escapeHtml(calendar.specialtyProfileName || '-')} ${escapeHtml(calendar.studyFormName || '-')} ${escapeHtml(String(calendar.courseCount || '-'))} `).join(''); } async function saveCalendar(event) { event.preventDefault(); hideAlert('academic-calendar-alert'); const payload = { id: calendarIdInput.value ? Number(calendarIdInput.value) : null, title: calendarTitleInput.value.trim(), academicYearId: Number(calendarYearSelect.value), specialtyId: Number(calendarSpecialtySelect.value), specialtyProfileId: Number(calendarProfileSelect.value), studyFormId: Number(calendarStudyFormSelect.value), courseCount: Number(calendarCourseCountInput.value) }; try { if (!payload.title || !payload.academicYearId || !payload.specialtyId || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { throw new Error('Заполните все поля календарного графика'); } const id = calendarIdInput.value; const saved = id ? await api.put('/api/admin/academic-calendars/' + id, payload) : await api.post('/api/admin/academic-calendars', payload); showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); resetCalendarForm(); await loadCalendars(); } catch (error) { showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error'); } } async function handleCalendarTableClick(event) { const editButton = event.target.closest('.btn-edit-calendar'); const gridButton = event.target.closest('.btn-open-calendar-grid'); const deleteButton = event.target.closest('.btn-delete-calendar'); if (editButton) { const calendar = calendars.find(item => item.id == editButton.dataset.id); if (!calendar) return; calendarFormTitle.textContent = 'Редактирование календарного графика'; calendarIdInput.value = calendar.id; calendarTitleInput.value = calendar.title || ''; calendarYearSelect.value = calendar.academicYearId || ''; calendarSpecialtySelect.value = calendar.specialtyId || ''; populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); calendarStudyFormSelect.value = calendar.studyFormId || ''; calendarCourseCountInput.value = calendar.courseCount || 4; syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); hideAlert('academic-calendar-alert'); calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (gridButton) { editorCalendarSelect.value = gridButton.dataset.id; syncSelects(editorCalendarSelect); await loadCalendarGrid(); calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (deleteButton) { if (!confirm('Удалить календарный график?')) return; try { await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); await loadCalendars(); } catch (error) { showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error'); } } } function resetCalendarForm() { calendarForm.reset(); calendarFormTitle.textContent = 'Календарный учебный график'; calendarIdInput.value = ''; calendarCourseCountInput.value = 4; populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); hideAlert('academic-calendar-alert'); } async function loadCalendarGrid() { hideAlert('calendar-editor-alert'); const calendar = selectedEditorCalendar(); if (!calendar) { showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); return; } try { const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); currentGridRows = rows.length ? rows : buildDefaultGrid(calendar); renderCalendarGrid(currentGridRows); showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); } catch (error) { showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); } } async function saveCalendarGrid() { hideAlert('calendar-editor-alert'); const calendar = selectedEditorCalendar(); if (!calendar) { showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); return; } const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ calendarId: calendar.id, courseNumber: Number(day.dataset.course), date: day.dataset.date, weekNumber: Number(day.dataset.week), dayOfWeek: Number(day.dataset.day), activityTypeId: Number(day.querySelector('.calendar-day-activity').value) })); if (!rows.length) { showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error'); return; } try { await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); await loadCalendarGrid(); } catch (error) { showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error'); } } function applyCalendarFill() { hideAlert('calendar-editor-alert'); const activityId = fillActivitySelect.value; const start = fillStartInput.value; const end = fillEndInput.value; if (!activityId || !start || !end) { showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); return; } if (end < start) { showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); return; } const course = fillCourseSelect.value; let changed = 0; calendarGrid.querySelectorAll('.calendar-day').forEach(day => { const sameCourse = !course || day.dataset.course === course; const inRange = day.dataset.date >= start && day.dataset.date <= end; if (sameCourse && inRange) { day.querySelector('.calendar-day-activity').value = activityId; changed += 1; } }); renderCalendarTotals(); showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); } function renderCalendarGrid(rows) { const grouped = groupBy(rows, row => row.courseNumber); calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `

${course} курс

${courseRows.length} дней
${courseRows.map(renderCalendarDay).join('')}
`).join(''); populateFillCourseSelect(); renderCalendarTotals(); } function renderCalendarDay(row) { const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); const dayName = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'][Number(row.dayOfWeek) - 1] || ''; return `
${formatShortDate(row.date)} ${dayName}
${row.weekNumber} нед.
`; } function buildDefaultGrid(calendar) { const rows = []; const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); for (let course = 1; course <= calendar.courseCount; course += 1) { dates.forEach((date, index) => { rows.push({ id: null, calendarId: calendar.id, courseNumber: course, date: date.iso, weekNumber: Math.floor(index / 7) + 1, dayOfWeek: date.dayOfWeek, activityTypeId: theoryActivityId(), activityCode: 'Т' }); }); } return rows; } function renderCalendarTotals() { const counts = new Map(); calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { const activity = activityById(select.value); const key = activity ? activity.code : '-'; counts.set(key, (counts.get(key) || 0) + 1); }); calendarTotals.innerHTML = Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru')) .map(([code, count]) => { const activity = activityTypes.find(item => item.code === code); return `${escapeHtml(code)}: ${count}`; }).join(''); } function populateSubjectSelects() { ruleSubjectSelect.innerHTML = '' + subjects.map(subject => ``).join(''); } function populateYearSelects() { const yearOptions = '' + academicYears.map(year => ``).join(''); semesterYearSelect.innerHTML = yearOptions; calendarYearSelect.innerHTML = yearOptions; syncSelects(semesterYearSelect, calendarYearSelect); } function populateSemesterSelects() { const semesterOptions = '' + semesters.map(semester => ``).join(''); ruleSemesterSelect.innerHTML = semesterOptions; syncSelects(ruleSemesterSelect); } function populateSpecialtySelects() { calendarSpecialtySelect.innerHTML = '' + specialties.map(speciality => ``).join(''); populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); syncSelects(calendarSpecialtySelect); } function populateProfileSelect(select, specialtyId, selectedProfileId = null) { const profiles = profilesBySpecialty.get(String(specialtyId)) || []; select.innerHTML = profiles.length ? '' + profiles.map(profile => `` ).join('') : ''; if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { select.value = selectedProfileId; } syncSelects(select); } function populateStudyFormSelect() { calendarStudyFormSelect.innerHTML = '' + studyForms.map(form => ``).join(''); syncSelects(calendarStudyFormSelect); } function populateActivitySelects() { const activityOptions = '' + activityTypes.map(type => ``).join(''); fillActivitySelect.innerHTML = activityOptions; syncSelects(fillActivitySelect); } function populateEditorCalendarSelect() { const current = editorCalendarSelect.value; editorCalendarSelect.innerHTML = '' + calendars.map(calendar => ``).join(''); if (current && calendars.some(calendar => String(calendar.id) === String(current))) { editorCalendarSelect.value = current; } syncSelects(editorCalendarSelect); } function populateFillCourseSelect() { const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); fillCourseSelect.innerHTML = '' + Array.from(courses).sort((a, b) => Number(a) - Number(b)) .map(course => ``).join(''); syncSelects(fillCourseSelect); } function options(items, selectedValue) { const selected = selectedValue == null ? '' : String(selectedValue); return '' + items.map(item => { const value = String(item.value); return ``; }).join(''); } function toActivityOption(type) { return { value: type.id, label: `${type.code} - ${type.name}` }; } 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 selectedEditorCalendar() { return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); } function theoryActivityId() { return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; } function activityIdByCode(code) { return activityTypes.find(type => type.code === code)?.id || null; } function activityById(id) { return activityTypes.find(type => String(type.id) === String(id)); } 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 specialityLabel(speciality) { const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; const name = speciality.specialityName || speciality.name || ''; return name ? `${code} - ${name}` : String(code); } function trimTime(value) { if (!value) return ''; return String(value).slice(0, 5); } function formatShortDate(value) { const [year, month, day] = String(value).split('-'); return `${day}.${month}`; } function enumerateDates(startIso, endIso) { const result = []; const current = new Date(`${startIso}T00:00:00Z`); const end = new Date(`${endIso}T00:00:00Z`); while (current <= end) { const iso = current.toISOString().slice(0, 10); const jsDay = current.getUTCDay(); result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); current.setUTCDate(current.getUTCDate() + 1); } return result; } function groupBy(items, keyFn) { const grouped = new Map(); items.forEach(item => { const key = keyFn(item); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(item); }); return grouped; } function syncSelects(...selects) { selects.filter(Boolean).forEach(select => { select.dispatchEvent(new Event('change', { bubbles: true })); }); } }