import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; const DAY_OPTIONS = [ { value: 1, label: 'Понедельник', shortLabel: 'Пн' }, { value: 2, label: 'Вторник', shortLabel: 'Вт' }, { value: 3, label: 'Среда', shortLabel: 'Ср' }, { value: 4, label: 'Четверг', shortLabel: 'Чт' }, { value: 5, label: 'Пятница', shortLabel: 'Пт' }, { value: 6, label: 'Суббота', shortLabel: 'Сб' }, { value: 7, label: 'Воскресенье', shortLabel: 'Вс' } ]; const SEMESTER_LABELS = { autumn: 'осенний', spring: 'весенний' }; export async function initAcademicCalendar() { 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'); const calendarDayDialog = document.getElementById('calendar-day-dialog'); const calendarDayDialogClose = document.getElementById('calendar-day-dialog-close'); const calendarDayDialogDate = document.getElementById('calendar-day-dialog-date'); const calendarDayDialogCourse = document.getElementById('calendar-day-dialog-course'); const calendarDayDialogWeek = document.getElementById('calendar-day-dialog-week'); const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day'); const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary'); const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity'); const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope'); const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save'); const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear'); const calendarTabButtons = Array.from(document.querySelectorAll('[data-calendar-tab]')); const calendarTabPanels = Array.from(document.querySelectorAll('[data-calendar-tab-panel]')); const subjectCalendarSelect = document.getElementById('calendar-subject-calendar'); const subjectSemesterSelect = document.getElementById('calendar-subject-semester'); const subjectSelect = document.getElementById('calendar-subject-id'); const subjectAddButton = document.getElementById('calendar-subject-add'); const subjectSaveButton = document.getElementById('calendar-subject-save'); const subjectRefreshButton = document.getElementById('calendar-subject-refresh'); const subjectSummary = document.getElementById('calendar-subject-summary'); const subjectCount = document.getElementById('calendar-subject-count'); let academicYears = []; let semesters = []; let specialties = []; let profilesBySpecialty = new Map(); let studyForms = []; let activityTypes = []; let calendars = []; let subjects = []; let calendarSubjectRows = []; let timeSlotScopes = []; let timeSlotDateAssignments = []; let activeCalendarDay = null; let selectedCalendarDayKey = null; let selectedCalendarDays = []; let isCalendarDragSelecting = false; let didCalendarDragSelect = false; let suppressCalendarDayClick = false; let isSyncingCalendarDayDialog = false; let calendarDayTooltip = null; bindEvents(); try { await Promise.all([ loadBaseLists(), loadYears(), loadSubjects(), loadStudyForms(), loadActivityTypes(), loadTimeSlotScopes(), loadTimeSlotDateAssignments() ]); await loadCalendars(); } catch (error) { showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error'); } function bindEvents() { calendarTabButtons.forEach(button => { button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab)); }); academicYearsRefreshButton?.addEventListener('click', loadYears); calendarsRefreshButton?.addEventListener('click', loadCalendars); 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); subjectCalendarSelect?.addEventListener('change', loadCalendarSubjects); subjectAddButton?.addEventListener('click', addCalendarSubject); subjectSaveButton?.addEventListener('click', saveCalendarSubjects); subjectRefreshButton?.addEventListener('click', loadCalendarSubjects); subjectSummary?.addEventListener('click', handleSubjectSummaryClick); calendarGrid?.addEventListener('change', (event) => { if (event.target.matches('.calendar-day-activity')) { updateCalendarDayVisual(event.target.closest('.calendar-day')); renderCalendarTotals(); } }); calendarGrid?.addEventListener('mousedown', (event) => { const day = event.target.closest('.calendar-day'); if (day) startCalendarDayDragSelection(event, day); }); calendarGrid?.addEventListener('mouseover', (event) => { const day = event.target.closest('.calendar-day'); if (day) addCalendarDayToDragSelection(day); }); calendarGrid?.addEventListener('mousemove', (event) => { const day = event.target.closest('.calendar-day'); if (day) showCalendarDayTooltip(day, event); }); calendarGrid?.addEventListener('mouseleave', hideCalendarDayTooltip); calendarGrid?.addEventListener('focusin', (event) => { const day = event.target.closest('.calendar-day'); if (day) showCalendarDayTooltip(day); }); calendarGrid?.addEventListener('focusout', hideCalendarDayTooltip); calendarGrid?.addEventListener('click', (event) => { if (suppressCalendarDayClick) { suppressCalendarDayClick = false; event.preventDefault(); return; } const day = event.target.closest('.calendar-day'); if (day) openCalendarDayDialog(day); }); document.addEventListener('mouseup', finishCalendarDayDragSelection); calendarGrid?.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const day = event.target.closest('.calendar-day'); if (!day) return; event.preventDefault(); openCalendarDayDialog(day); }); calendarDayDialogClose?.addEventListener('click', closeCalendarDayDialog); calendarDayDialog?.addEventListener('click', (event) => { if (event.target === calendarDayDialog) closeCalendarDayDialog(); }); calendarDayDialogActivitySelect?.addEventListener('change', () => { if (isSyncingCalendarDayDialog || !calendarDayDialogActivitySelect.value) return; const days = selectedCalendarDays.length ? selectedCalendarDays : (activeCalendarDay ? [activeCalendarDay] : []); days.forEach(day => { const select = day.querySelector('.calendar-day-activity'); select.value = calendarDayDialogActivitySelect.value; updateCalendarDayVisual(day); }); updateCalendarDayDialogSummaryForSelection(days); renderCalendarTotals(); }); calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); academicYearForm?.addEventListener('submit', saveAcademicYear); semesterForm?.addEventListener('submit', saveSemester); calendarForm?.addEventListener('submit', saveCalendar); academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick); calendarsTbody?.addEventListener('click', handleCalendarTableClick); } async function loadBaseLists() { specialties = await api.get('/api/specialties'); await loadProfiles(); 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 loadYears() { academicYearsTbody.innerHTML = 'Загрузка...'; try { academicYears = await api.get('/api/admin/calendar/years'); semesters = academicYears.flatMap(year => year.semesters || []); renderAcademicYears(); populateYearSelects(); } catch (error) { academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; throw error; } } function renderAcademicYears() { if (!academicYears.length) { academicYearsTbody.innerHTML = 'Учебные годы не созданы'; return; } academicYearsTbody.innerHTML = academicYears.map(year => ` ${escapeHtml(year.title)} ${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))} ${renderSemesterList(year.semesters || [])}
`).join(''); } function renderSemesterList(items) { if (!items.length) return 'Нет семестров'; return `
${items.map(semester => { const title = semesterTypeTitle(semester); const period = formatDisplayDateRange(semester.startDate, semester.endDate); return `
${escapeHtml(title)} ${escapeHtml(period)}
`; }).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 = `Редактирование семестра: ${semesterTypeTitle(semester)}`; 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/education-forms'); populateStudyFormSelect(); } async function loadSubjects() { subjects = await api.get('/api/subjects'); populateSubjectSelect(); } async function loadActivityTypes() { activityTypes = await api.get('/api/admin/calendar/activity-types'); populateActivitySelects(); } async function loadTimeSlotScopes() { timeSlotScopes = await api.get('/api/admin/time-slots/scopes'); } async function loadTimeSlotDateAssignments() { timeSlotDateAssignments = await api.get('/api/admin/time-slots/date-assignments'); } async function loadCalendars() { calendarsTbody.innerHTML = 'Загрузка...'; try { calendars = await api.get('/api/admin/academic-calendars'); renderCalendars(); populateEditorCalendarSelect(); populateSubjectCalendarSelect(); } catch (error) { calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; throw error; } } 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 subjectsButton = event.target.closest('.btn-open-calendar-subjects'); const deleteButton = event.target.closest('.btn-delete-calendar'); if (editButton) { activateCalendarTab('manage'); 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) { activateCalendarTab('grid'); editorCalendarSelect.value = gridButton.dataset.id; refreshSelectVisuals(editorCalendarSelect); await loadCalendarGrid(); calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (subjectsButton) { activateCalendarTab('subjects'); subjectCalendarSelect.value = subjectsButton.dataset.id; refreshSelectVisuals(subjectCalendarSelect); await loadCalendarSubjects(); subjectSummary.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'); } function activateCalendarTab(tabName) { const target = tabName || 'manage'; calendarTabButtons.forEach(button => { const active = button.dataset.calendarTab === target; button.classList.toggle('active', active); button.setAttribute('aria-selected', active ? 'true' : 'false'); }); calendarTabPanels.forEach(panel => { const active = panel.dataset.calendarTabPanel === target; panel.classList.toggle('active', active); panel.hidden = !active; }); } async function loadCalendarSubjects() { hideAlert('calendar-subject-alert'); const calendar = selectedSubjectCalendar(); populateSubjectSemesterSelect(); calendarSubjectRows = []; if (!calendar) { renderCalendarSubjects(); return; } subjectSummary.innerHTML = '
Загрузка...
'; try { const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); calendarSubjectRows = sortCalendarSubjectRows(rows || []); renderCalendarSubjects(); } catch (error) { subjectSummary.innerHTML = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error'); } } function addCalendarSubject() { hideAlert('calendar-subject-alert'); const calendar = selectedSubjectCalendar(); const semesterNumber = Number(subjectSemesterSelect.value); const subjectId = Number(subjectSelect.value); if (!calendar || !semesterNumber || !subjectId) { showAlert('calendar-subject-alert', 'Выберите график, семестр и дисциплину', 'error'); return; } if (calendarSubjectRows.some(row => Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId )) { showAlert('calendar-subject-alert', 'Дисциплина уже добавлена в этот семестр', 'error'); return; } const subject = subjects.find(item => Number(item.id) === subjectId); if (!subject) { showAlert('calendar-subject-alert', 'Дисциплина не найдена в справочнике', 'error'); return; } calendarSubjectRows = sortCalendarSubjectRows([ ...calendarSubjectRows, { id: null, calendarId: calendar.id, semesterNumber, subjectId, subjectName: subject.name, subjectCode: subject.code, departmentId: subject.departmentId } ]); renderCalendarSubjects(); showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success'); } async function saveCalendarSubjects() { hideAlert('calendar-subject-alert'); const calendar = selectedSubjectCalendar(); if (!calendar) { showAlert('calendar-subject-alert', 'Выберите календарный график', 'error'); return; } const payload = calendarSubjectRows.map(row => ({ semesterNumber: Number(row.semesterNumber), subjectId: Number(row.subjectId) })); try { const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload); calendarSubjectRows = sortCalendarSubjectRows(saved || []); renderCalendarSubjects(); showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success'); } catch (error) { showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error'); } } function handleSubjectSummaryClick(event) { const removeButton = event.target.closest('.calendar-subject-remove'); if (!removeButton) return; const semesterNumber = Number(removeButton.dataset.semester); const subjectId = Number(removeButton.dataset.subject); calendarSubjectRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId ); renderCalendarSubjects(); showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success'); } function renderCalendarSubjects() { const calendar = selectedSubjectCalendar(); if (!calendar) { subjectCount.textContent = ''; subjectSummary.innerHTML = '
Выберите календарный график
'; return; } const semesterCount = calendarSemesterCount(calendar); subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`; subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => { const semesterNumber = index + 1; const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber); return `
${escapeHtml(semesterOptionLabel(semesterNumber))} ${semesterRows.length} дисциплин
${semesterRows.length ? `
${semesterRows.map(renderSubjectChip).join('')}
` : '
Нет дисциплин
'}
`; }).join(''); } function renderSubjectChip(row) { return ` ${escapeHtml(subjectRowLabel(row))} `; } async function loadCalendarGrid() { hideAlert('calendar-editor-alert'); const calendar = selectedEditorCalendar(); if (!calendar) { showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); return; } try { await loadTimeSlotDateAssignments(); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); const gridRows = rows.length ? rows : buildDefaultGrid(calendar); renderCalendarGrid(gridRows); 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; updateCalendarDayVisual(day); changed += 1; } }); renderCalendarTotals(); showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); } function renderCalendarGrid(rows) { activeCalendarDay = null; selectedCalendarDayKey = null; selectedCalendarDays = []; const grouped = groupBy(rows, row => row.courseNumber); calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
${course} курс ${courseRows.length} дней
${renderCourseSemesters(Number(course), courseRows)}
`).join(''); populateFillCourseSelect(); renderCalendarTotals(); } function renderCourseSemesters(course, courseRows) { const calendar = selectedEditorCalendar(); const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); const maxWeekColumns = Math.max( ...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length), 1 ); return semestersForCourse.map(block => `

${block.semesterNumber} семестр

${escapeHtml(block.caption)}
${renderSemesterTable(block.rows, maxWeekColumns)}
`).join(''); } function renderSemesterTable(rows, targetWeekColumns = 0) { if (!rows.length) { return '
Нет дат в этом семестре
'; } const rowsByWeek = groupBy(rows, row => row.weekNumber); const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); const weekSlots = [...weeks]; while (weekSlots.length < targetWeekColumns) { weekSlots.push(null); } const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); return `
${weekSlots.map(() => '').join('')} ${weekSlots.map(week => week === null ? '' : ``).join('')} ${DAY_OPTIONS.map(day => ` ${weekSlots.map(week => { if (week === null) { return ''; } const row = cellByWeekAndDay.get(`${week}:${day.value}`); return row ? renderCalendarDay(row) : ''; }).join('')} `).join('')}
День${week}
${escapeHtml(day.shortLabel)}
`; } function uniqueWeekNumbers(rows) { return Array.from(new Set(rows.map(row => row.weekNumber))); } function renderCalendarDay(row) { const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); const dayName = shortDayLabel(row.dayOfWeek); const activity = activityById(selected) || activityTypes[0] || {}; const code = activity.code || row.activityCode || 'Т'; const color = activity.colorCode || '#64748b'; return ` ${escapeHtml(code)} ${timeAssignmentForDate(row.date) ? 'вр.' : ''} `; } function openCalendarDayDialog(day) { openCalendarDayDialogForSelection([day]); } function openCalendarDayDialogForSelection(days) { const selectedDays = uniqueCalendarDays(days); if (!selectedDays.length) return; activeCalendarDay = selectedDays[0]; selectCalendarDays(selectedDays); const dates = selectedDays.map(day => day.dataset.date).sort(); const courses = new Set(selectedDays.map(day => day.dataset.course)); const weeks = new Set(selectedDays.map(day => day.dataset.week)); const weekDays = new Set(selectedDays.map(day => day.dataset.day)); const isSingle = selectedDays.length === 1; calendarDayDialogDate.textContent = isSingle ? `${formatLongDate(activeCalendarDay.dataset.date)} (${shortDayLabel(activeCalendarDay.dataset.day)})` : `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; calendarDayDialogCourse.textContent = courses.size === 1 ? `${activeCalendarDay.dataset.course} курс` : `${courses.size} курса`; calendarDayDialogWeek.textContent = weeks.size === 1 ? `${activeCalendarDay.dataset.week} неделя` : `${weeks.size} недель`; calendarDayDialogDay.textContent = weekDays.size === 1 ? dayLabel(activeCalendarDay.dataset.day) : `${selectedDays.length} ячеек`; calendarDayDialogActivitySelect.innerHTML = options( activityTypes.map(toActivityOption), commonCalendarDayActivityValue(selectedDays) ); if (isSingle) { populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); } else { populateMultiSelectionTimeScopeDialogSelect(); } isSyncingCalendarDayDialog = true; syncSelects(calendarDayDialogActivitySelect); syncSelects(calendarDayDialogTimeScopeSelect); isSyncingCalendarDayDialog = false; updateCalendarDayDialogSummaryForSelection(selectedDays); renderCalendarTotals(); calendarDayDialog.classList.add('open'); } function closeCalendarDayDialog() { activeCalendarDay = null; calendarDayDialog.classList.remove('open'); } function updateCalendarDayDialogSummary(day) { const activity = activityById(day.querySelector('.calendar-day-activity').value); if (!activity) { calendarDayDialogActivitySummary.innerHTML = ''; return; } calendarDayDialogActivitySummary.innerHTML = ` ${escapeHtml(activity.code)}
${escapeHtml(activity.name)}

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

`; } function updateCalendarDayDialogSummaryForSelection(days) { if (days.length <= 1) { updateCalendarDayDialogSummary(days[0]); return; } const activity = activityById(commonCalendarDayActivityValue(days)); if (!activity) { calendarDayDialogActivitySummary.innerHTML = ` ...
Выбрано ячеек: ${days.length}

Выберите код активности, чтобы применить его ко всему выделению

`; return; } calendarDayDialogActivitySummary.innerHTML = ` ${escapeHtml(activity.code)}
${escapeHtml(activity.name)} · ${days.length} ячеек

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

`; } async function saveTimeSlotDateAssignment() { if (!activeCalendarDay) return; hideAlert('calendar-day-dialog-time-alert'); const scopeId = calendarDayDialogTimeScopeSelect.value; if (!scopeId) { await clearTimeSlotDateAssignment(); return; } try { await api.post('/api/admin/time-slots/date-assignments', { date: activeCalendarDay.dataset.date, scopeId: Number(scopeId) }); await loadTimeSlotDateAssignments(); updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); syncSelects(calendarDayDialogTimeScopeSelect); showAlert('calendar-day-dialog-time-alert', 'Ручная сетка применена к дате', 'success'); } catch (error) { showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка применения временной сетки', 'error'); } } async function clearTimeSlotDateAssignment() { if (!activeCalendarDay) return; hideAlert('calendar-day-dialog-time-alert'); const assignment = timeAssignmentForDate(activeCalendarDay.dataset.date); if (!assignment) { calendarDayDialogTimeScopeSelect.value = ''; syncSelects(calendarDayDialogTimeScopeSelect); showAlert('calendar-day-dialog-time-alert', 'Для даты нет ручной сетки', 'success'); return; } try { await api.delete('/api/admin/time-slots/date-assignments/' + assignment.id); await loadTimeSlotDateAssignments(); updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); syncSelects(calendarDayDialogTimeScopeSelect); showAlert('calendar-day-dialog-time-alert', 'Ручная сетка убрана', 'success'); } catch (error) { showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка удаления ручной сетки', 'error'); } } function populateTimeScopeDialogSelect(date) { const assignment = timeAssignmentForDate(date); const manualScopes = timeSlotScopes.filter(scope => scope.applyMode === 'MANUAL'); const automaticLabel = automaticTimeScopeLabel(date); calendarDayDialogTimeScopeSelect.innerHTML = `` + manualScopes.map(scope => ``).join(''); calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; calendarDayDialogTimeScopeSelect.disabled = false; calendarDayDialogTimeSaveButton.disabled = false; calendarDayDialogTimeClearButton.disabled = !assignment; } function populateMultiSelectionTimeScopeDialogSelect() { calendarDayDialogTimeScopeSelect.innerHTML = ''; calendarDayDialogTimeScopeSelect.value = ''; calendarDayDialogTimeScopeSelect.disabled = true; calendarDayDialogTimeSaveButton.disabled = false; calendarDayDialogTimeClearButton.disabled = true; } function updateTimeAssignmentVisuals(date) { const assignment = timeAssignmentForDate(date); calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { day.dataset.timeAssignmentId = assignment?.id || ''; day.dataset.timeScopeId = assignment?.scopeId || ''; const marker = day.querySelector('.calendar-day-time-marker'); if (assignment && !marker) { day.insertAdjacentHTML('beforeend', 'вр.'); } if (!assignment && marker) { marker.remove(); } }); } function updateCalendarDayVisual(day) { if (!day) return; const select = day.querySelector('.calendar-day-activity'); const activity = activityById(select.value); const code = activity?.code || '-'; const color = activity?.colorCode || '#64748b'; day.style.setProperty('--activity-color', color); day.querySelector('.calendar-day-code').textContent = code; day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); } function showCalendarDayTooltip(day, event = null) { if (isCalendarDragSelecting) { hideCalendarDayTooltip(); return; } const tooltip = ensureCalendarDayTooltip(); const activity = activityById(day.querySelector('.calendar-day-activity')?.value); tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b'); tooltip.innerHTML = renderCalendarDayTooltip(day); tooltip.classList.add('visible'); positionCalendarDayTooltip(tooltip, day, event); } function hideCalendarDayTooltip() { calendarDayTooltip?.classList.remove('visible'); } function ensureCalendarDayTooltip() { if (calendarDayTooltip) return calendarDayTooltip; calendarDayTooltip = document.createElement('div'); calendarDayTooltip.className = 'calendar-day-tooltip'; calendarDayTooltip.setAttribute('role', 'tooltip'); document.body.appendChild(calendarDayTooltip); return calendarDayTooltip; } function renderCalendarDayTooltip(day) { const activity = activityById(day.querySelector('.calendar-day-activity')?.value); const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-'; const color = activity?.colorCode || '#64748b'; const timeAssignment = timeAssignmentForDate(day.dataset.date); const timeLabel = timeAssignment ? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка') : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); return `
${escapeHtml(code)}
${escapeHtml(formatLongDate(day.dataset.date))} ${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя
Курс ${escapeHtml(day.dataset.course)}
Активность ${escapeHtml(activity?.name || 'Не задана')}
Время ${escapeHtml(timeLabel)}
`; } function positionCalendarDayTooltip(tooltip, day, event) { const offset = 14; const rect = day.getBoundingClientRect(); const x = event?.clientX ?? rect.left + rect.width / 2; const y = event?.clientY ?? rect.top; const tooltipRect = tooltip.getBoundingClientRect(); let left = x + offset; let top = y + offset; if (left + tooltipRect.width > window.innerWidth - 12) { left = x - tooltipRect.width - offset; } if (top + tooltipRect.height > window.innerHeight - 12) { top = y - tooltipRect.height - offset; } tooltip.style.left = `${Math.max(12, left)}px`; tooltip.style.top = `${Math.max(12, top)}px`; } function startCalendarDayDragSelection(event, day) { if (event.button !== 0) return; hideCalendarDayTooltip(); isCalendarDragSelecting = true; didCalendarDragSelect = false; suppressCalendarDayClick = false; selectCalendarDays([day]); event.preventDefault(); } function addCalendarDayToDragSelection(day) { if (!isCalendarDragSelecting || selectedCalendarDays.includes(day)) return; didCalendarDragSelect = true; selectedCalendarDays.push(day); day.classList.add('is-selected'); renderCalendarTotals(); } function finishCalendarDayDragSelection() { if (!isCalendarDragSelecting) return; isCalendarDragSelecting = false; if (didCalendarDragSelect && selectedCalendarDays.length > 1) { suppressCalendarDayClick = true; openCalendarDayDialogForSelection(selectedCalendarDays); } } function selectCalendarDay(day) { selectCalendarDays(day ? [day] : []); } function selectCalendarDays(days) { const uniqueDays = uniqueCalendarDays(days); selectedCalendarDays = uniqueDays; selectedCalendarDayKey = uniqueDays[0] ? calendarDayKey(uniqueDays[0]) : null; calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); uniqueDays.forEach(item => item.classList.add('is-selected')); renderCalendarTotals(); } function uniqueCalendarDays(days) { return Array.from(new Set(days.filter(Boolean))); } function commonCalendarDayActivityValue(days) { const values = uniqueCalendarDays(days).map(day => day.querySelector('.calendar-day-activity')?.value || ''); const uniqueValues = Array.from(new Set(values)); return uniqueValues.length === 1 ? uniqueValues[0] : ''; } function calendarDayKey(day) { return `${day.dataset.course}:${day.dataset.date}`; } 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 semesterBlocksForCourse(calendar, course, courseRows) { const sortedRows = [...courseRows].sort((a, b) => String(a.date).localeCompare(String(b.date))); const year = academicYears.find(item => String(item.id) === String(calendar?.academicYearId)); const yearSemesters = [...(year?.semesters || [])] .sort((a, b) => String(a.startDate).localeCompare(String(b.startDate))); if (!yearSemesters.length) { return [{ semesterNumber: `${course * 2 - 1}-${course * 2}`, caption: dateRangeCaption(sortedRows), rows: sortedRows }]; } return yearSemesters.map((semester, index) => { const nextSemester = yearSemesters[index + 1]; const startDate = index === 0 ? minIso(calendar.academicYearStartDate, semester.startDate) : semester.startDate; const endDate = nextSemester ? previousIsoDate(nextSemester.startDate) : maxIso(calendar.academicYearEndDate, semester.endDate); const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate); return { semesterNumber: course * 2 - 1 + index, caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`, 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); }); const selectedDays = selectedCalendarDays.length ? selectedCalendarDays : (selectedCalendarDayKey ? [Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey)].filter(Boolean) : []); const selectedActivity = selectedDays.length ? activityById(commonCalendarDayActivityValue(selectedDays)) : null; const selectedCode = selectedActivity?.code || null; calendarTotals.innerHTML = Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru')) .map(([code, count]) => { const activity = activityTypes.find(item => item.code === code); const selectedClass = selectedCode === code ? ' is-selected' : ''; const suffix = selectedCode === code ? 'выбрано' : ''; return `${escapeHtml(code)}: ${count}${suffix}`; }).join(''); } function populateYearSelects() { const yearOptions = '' + academicYears.map(year => ``).join(''); semesterYearSelect.innerHTML = yearOptions; calendarYearSelect.innerHTML = yearOptions; syncSelects(semesterYearSelect, calendarYearSelect); } 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 populateSubjectSelect() { if (!subjectSelect) return; const current = subjectSelect.value; const sortedSubjects = [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru')); subjectSelect.innerHTML = sortedSubjects.length ? '' + sortedSubjects.map(subject => `` ).join('') : ''; if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) { subjectSelect.value = current; } syncSelects(subjectSelect); } 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 populateSubjectCalendarSelect() { if (!subjectCalendarSelect) return; const current = subjectCalendarSelect.value; subjectCalendarSelect.innerHTML = calendars.length ? '' + calendars.map(calendar => `` ).join('') : ''; if (current && calendars.some(calendar => String(calendar.id) === String(current))) { subjectCalendarSelect.value = current; } populateSubjectSemesterSelect(); syncSelects(subjectCalendarSelect); } function populateSubjectSemesterSelect() { if (!subjectSemesterSelect) return; const calendar = selectedSubjectCalendar(); const current = subjectSemesterSelect.value; if (!calendar) { subjectSemesterSelect.innerHTML = ''; syncSelects(subjectSemesterSelect); return; } const semesterCount = calendarSemesterCount(calendar); subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => { const semesterNumber = index + 1; return ``; }).join(''); if (current && Number(current) >= 1 && Number(current) <= semesterCount) { subjectSemesterSelect.value = current; } syncSelects(subjectSemesterSelect); } 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 selectedEditorCalendar() { return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); } function selectedSubjectCalendar() { return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value)); } function calendarSemesterCount(calendar) { return Math.max(1, Number(calendar?.courseCount || 0) * 2); } function semesterOptionLabel(semesterNumber) { const course = Math.ceil(Number(semesterNumber) / 2); const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний'; return `${semesterNumber} семестр · ${course} курс, ${type}`; } function sortCalendarSubjectRows(rows) { return [...rows].sort((a, b) => { const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber); if (semesterDiff !== 0) return semesterDiff; return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru'); }); } function subjectLabel(subject) { const code = subject.code ? `${subject.code} · ` : ''; return `${code}${subject.name || 'Без названия'}`; } function subjectRowLabel(row) { const code = row.subjectCode ? `${row.subjectCode} · ` : ''; return `${code}${row.subjectName || row.subjectId || 'Без названия'}`; } 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 timeAssignmentForDate(date) { return timeSlotDateAssignments.find(item => item.date === date); } function automaticTimeScopeLabel(date) { const jsDate = new Date(`${date}T00:00:00`); const jsDay = jsDate.getDay(); const isoDay = jsDay === 0 ? 7 : jsDay; const weekdayScope = timeSlotScopes.find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay); const defaultScope = timeSlotScopes.find(scope => scope.applyMode === 'DEFAULT'); const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка'; return `По правилу даты (${scopeName})`; } function semesterLabel(semester) { const type = normalizeSemesterType(semester.semesterType); return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`; } function semesterTypeTitle(semester) { const type = normalizeSemesterType(semester.semesterType); const label = SEMESTER_LABELS[type] || type || 'семестр'; return label.charAt(0).toUpperCase() + label.slice(1); } 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 dayLabel(value) { return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; } function shortDayLabel(value) { return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-'; } function formatShortDate(value) { const [year, month, day] = String(value).split('-'); return `${day}.${month}`; } function formatLongDate(value) { const [year, month, day] = String(value).split('-'); return `${day}.${month}.${year}`; } function formatDisplayDate(value) { const [year, month, day] = String(value || '').split('-'); if (!year || !month || !day) return String(value || ''); return `${day}-${month}-${year}`; } function formatDisplayDateRange(startDate, endDate) { return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`; } function dateRangeCaption(rows) { if (!rows.length) return ''; const dates = rows.map(row => row.date).sort(); return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; } function minIso(a, b) { return String(a) <= String(b) ? a : b; } function maxIso(a, b) { return String(a) >= String(b) ? a : b; } function previousIsoDate(value) { const date = new Date(`${value}T00:00:00Z`); date.setUTCDate(date.getUTCDate() - 1); return date.toISOString().slice(0, 10); } 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 => { refreshSelectVisuals(select); select.dispatchEvent(new Event('change', { bubbles: true })); }); } function refreshSelectVisuals(...selects) { selects.filter(Boolean).forEach(select => { if (select.customSelectInstance) { select.customSelectInstance.rebuildMenu(); } }); } }