import { api } from '../api.js';
import { AsyncCombobox } from '../async-combobox.js';
import { renderPagination } from '../pagination.js';
import { debounce } from '../url-state.js';
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
import { confirmAction } from '../dialog.js';
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
import { trackUxEvent } from '../../../telemetry.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: 'весенний'
};
const CALENDAR_TOOLTIP_OFFSET = 14;
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
export function calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth,
tooltipHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
const maxTop = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
if (left > maxLeft) {
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
}
if (top > maxTop) {
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
}
return {
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
export function academicYearDefaults(startYearValue) {
const value = String(startYearValue || '').trim();
if (!/^\d{4}$/.test(value)) {
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
}
const startYear = Number(value);
if (startYear < 1000 || startYear > 9998) {
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
}
const endYear = startYear + 1;
const start = new Date(Date.UTC(startYear, 8, 1));
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
const springStart = new Date(Date.UTC(endYear, 1, 1));
const end = new Date(Date.UTC(endYear, 5, 30));
return {
startYear,
endYear,
title: `${startYear}-${endYear}`,
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(end),
semesters: [
{
semesterType: 'autumn',
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(autumnEnd)
},
{
semesterType: 'spring',
startDate: toIsoDateUtc(springStart),
endDate: toIsoDateUtc(end)
}
]
};
}
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
if (!Array.isArray(semesters) || semesters.length === 0) {
throw new Error('Учебный год нельзя создать без семестров');
}
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
const types = new Set();
sorted.forEach((semester, index) => {
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
throw new Error('Заполните тип и даты каждого семестра');
}
if (types.has(semester.semesterType)) {
throw new Error('Типы семестров не должны повторяться');
}
types.add(semester.semesterType);
if (semester.endDate < semester.startDate) {
throw new Error('Дата окончания семестра не может быть раньше даты начала');
}
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
throw new Error('Семестры должны находиться в границах учебного года');
}
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
throw new Error('Периоды семестров не должны пересекаться');
}
});
}
function toIsoDateUtc(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
export function activityColorCode(activity) {
const color = String(activity?.colorCode || '').trim();
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
}
export function activityColorClass(activity) {
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
}
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 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 academicYearSemesterInputs = {
autumn: {
id: document.getElementById('academic-year-autumn-id'),
start: document.getElementById('academic-year-autumn-start'),
end: document.getElementById('academic-year-autumn-end')
},
spring: {
id: document.getElementById('academic-year-spring-id'),
start: document.getElementById('academic-year-spring-start'),
end: document.getElementById('academic-year-spring-end')
}
};
const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
const calendarIdInput = document.getElementById('academic-calendar-id');
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 specialties = [];
let profilesBySpecialty = new Map();
let studyForms = [];
let activityTypes = [];
let calendars = [];
let calendarPage = 0;
let calendarSize = 25;
let calendarRequest = 0;
const calendarCache = new Map();
const calendarComboboxes = new Map();
const calendarSearch = document.getElementById('academic-calendars-search');
const calendarYearFilter = document.getElementById('academic-calendars-year-filter');
const calendarPagination = document.getElementById('academic-calendars-pagination');
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;
let calendarDayTooltipPositionAnimation = null;
let loadedGridCalendarId = '';
let loadedSubjectCalendarId = '';
let subjectLoadRequest = 0;
let gridLoadRequest = 0;
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
[editorCalendarSelect, subjectCalendarSelect].filter(Boolean).forEach(select => {
select.innerHTML = '';
calendarComboboxes.set(select, new AsyncCombobox(select, {
placeholder: 'Поиск по направлению, профилю, форме или году',
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title,
optionDescription: calendar => calendar.specialtyName || '',
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: 20 });
if (selectedId) params.set('selectedId', selectedId);
const items = await api.get(`/api/admin/academic-calendars/options?${params}`);
items.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
return items;
}
}));
});
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() {
[
academicYearSemesterInputs.autumn.start,
academicYearSemesterInputs.autumn.end,
academicYearSemesterInputs.spring.start,
academicYearSemesterInputs.spring.end,
fillStartInput,
fillEndInput
].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => {
button.addEventListener('click', async () => {
if (!await confirmAbandonDirtyChanges()) return;
activateCalendarTab(button.dataset.calendarTab);
});
});
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarSearch?.addEventListener('input', debounce(() => {
calendarPage = 0;
loadCalendars();
}, 300));
calendarYearFilter?.addEventListener('change', () => {
calendarPage = 0;
loadCalendars();
});
calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
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();
markGridDirty();
}
});
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();
markGridDirty();
});
calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog);
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
academicYearForm?.addEventListener('submit', saveAcademicYear);
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');
renderAcademicYears();
populateYearSelects();
const selectedYear = calendarYearFilter.value;
calendarYearFilter.innerHTML = '' + academicYears.map(year =>
``).join('');
calendarYearFilter.value = selectedYear;
refreshSelectVisuals(calendarYearFilter);
} 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');
try {
const defaults = applyAcademicYearDateDefaults(true);
const { startDate, endDate } = defaults;
const payload = {
startDate,
endDate,
semesters: ['autumn', 'spring'].map(type => readAcademicYearSemester(type))
};
validateAcademicYearSemesters(payload.semesters, startDate, endDate);
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;
fillAcademicYearForm(year);
return;
}
if (editSemesterButton) {
const year = academicYears.find(item => (item.semesters || [])
.some(semester => String(semester.id) === String(editSemesterButton.dataset.id)));
if (!year) return;
const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id));
fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType));
return;
}
if (deleteYearButton) {
const confirmed = await confirmAction({
title: 'Удаление учебного года',
message: 'Удалить учебный год вместе с семестрами и календарными графиками?',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) 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 = '';
Object.values(academicYearSemesterInputs).forEach(inputs => {
inputs.id.value = '';
});
hideAlert('academic-year-alert');
}
function fillAcademicYearForm(year, focusedSemesterType = '') {
academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim();
academicYearIdInput.value = year.id;
academicYearStartInput.value = String(year.startDate || '').slice(0, 4);
academicYearEndInput.value = String(year.endDate || '').slice(0, 4);
['autumn', 'spring'].forEach(type => {
const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type);
const inputs = academicYearSemesterInputs[type];
inputs.id.value = semester?.id || '';
setDateInputIso(inputs.start, semester?.startDate || '');
setDateInputIso(inputs.end, semester?.endDate || '');
});
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) {
academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true });
}
}
function handleAcademicYearStartInput() {
academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4);
if (academicYearStartInput.value.length !== 4) {
academicYearEndInput.value = '';
return;
}
try {
applyAcademicYearDateDefaults();
} catch (_) {
academicYearEndInput.value = '';
}
}
function applyAcademicYearDateDefaults(preserveSemesterDates = false) {
const defaults = academicYearDefaults(academicYearStartInput.value);
academicYearEndInput.value = String(defaults.endYear);
const semesterDefaults = {
autumn: defaults.semesters[0],
spring: defaults.semesters[1]
};
['autumn', 'spring'].forEach(type => {
const inputs = academicYearSemesterInputs[type];
if (!preserveSemesterDates || !inputs.start.value) {
setDateInputIso(inputs.start, semesterDefaults[type].startDate);
}
if (!preserveSemesterDates || !inputs.end.value) {
setDateInputIso(inputs.end, semesterDefaults[type].endDate);
}
});
return defaults;
}
function readAcademicYearSemester(type) {
const inputs = academicYearSemesterInputs[type];
return {
id: inputs.id.value ? Number(inputs.id.value) : null,
semesterType: type,
startDate: readDateInputIso(inputs.start, `Начало ${SEMESTER_LABELS[type]} семестра`),
endDate: readDateInputIso(inputs.end, `Окончание ${SEMESTER_LABELS[type]} семестра`)
};
}
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() {
const request = ++calendarRequest;
calendarPagination?.replaceChildren();
calendarsTbody.innerHTML = '| Загрузка... |
';
try {
const params = new URLSearchParams({ page: calendarPage, size: calendarSize, query: calendarSearch?.value.trim() || '' });
if (calendarYearFilter?.value) params.set('academicYearId', calendarYearFilter.value);
const page = await api.get(`/api/admin/academic-calendars/page?${params}`);
if (request !== calendarRequest) return;
if (calendarPage > 0 && calendarPage >= page.totalPages) {
calendarPage = Math.max(0, page.totalPages - 1);
return loadCalendars();
}
calendars = page.items;
calendars.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
renderCalendars();
renderPagination(calendarPagination, page, {
label: 'графиков',
onPage: value => { calendarPage = value; loadCalendars(); },
onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); }
});
} catch (error) {
if (request !== calendarRequest) return;
calendarsTbody.innerHTML = `| Ошибка загрузки: ${escapeHtml(error.message)} |
`;
throw error;
}
}
function renderCalendars() {
if (!calendars.length) {
calendarsTbody.innerHTML = '| Календарные графики не найдены |
';
return;
}
calendarsTbody.innerHTML = calendars.map(calendar => `
| ${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,
academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value),
studyFormId: Number(calendarStudyFormSelect.value),
courseCount: Number(calendarCourseCountInput.value)
};
try {
if (!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);
calendarCache.set(String(saved.id), saved);
calendarComboboxes.forEach((combobox, select) => {
if (select.value === String(saved.id)) setCalendarSelection(select, saved.id);
});
const savedTitle = buildAcademicCalendarTitle(saved);
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, '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;
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');
setCalendarSelection(editorCalendarSelect, gridButton.dataset.id);
refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (subjectsButton) {
activateCalendarTab('subjects');
setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id);
refreshSelectVisuals(subjectCalendarSelect);
await loadCalendarSubjects();
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
const confirmed = await confirmAction({
title: 'Удаление календарного графика',
message: 'Удалить календарный график? Связанные настройки больше не будут доступны.',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) return;
try {
const deletedId = deleteButton.dataset.id;
await api.delete('/api/admin/academic-calendars/' + deletedId);
calendarCache.delete(deletedId);
calendarComboboxes.forEach((combobox, select) => {
if (select.value === deletedId) {
setCalendarSelection(select, '');
if (select === editorCalendarSelect) {
loadedGridCalendarId = '';
calendarGrid.replaceChildren();
calendarTotals.replaceChildren();
clearGridDirty();
} else {
loadCalendarSubjects();
}
}
});
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() {
const request = ++subjectLoadRequest;
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId);
refreshSelectVisuals(subjectCalendarSelect);
return;
}
if (request !== subjectLoadRequest) return;
populateSubjectSemesterSelect();
calendarSubjectRows = [];
loadedSubjectCalendarId = '';
if (!calendar) {
loadedSubjectCalendarId = '';
clearSubjectDirty();
renderCalendarSubjects();
return;
}
subjectSummary.innerHTML = 'Загрузка...
';
try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return;
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
loadedSubjectCalendarId = String(calendar.id);
clearSubjectDirty();
renderCalendarSubjects();
} catch (error) {
if (request !== subjectLoadRequest) return;
subjectSummary.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}
`;
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
}
}
function addCalendarSubject() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
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();
markSubjectDirty();
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
}
async function saveCalendarSubjects() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
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();
clearSubjectDirty();
trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length });
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();
markSubjectDirty();
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() {
const request = ++gridLoadRequest;
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
setCalendarSelection(editorCalendarSelect, loadedGridCalendarId);
refreshSelectVisuals(editorCalendarSelect);
return;
}
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
try {
await loadTimeSlotDateAssignments();
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
if (request !== gridLoadRequest || String(calendar.id) !== editorCalendarSelect.value) return;
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(gridRows);
loadedGridCalendarId = String(calendar.id);
clearGridDirty();
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) {
if (request !== gridLoadRequest) return;
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;
}
if (String(calendar.id) !== loadedGridCalendarId) {
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);
clearGridDirty();
trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length });
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;
if (!activityId || !fillStartInput.value || !fillEndInput.value) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
return;
}
let start;
let end;
try {
start = readDateInputIso(fillStartInput, 'Дата начала диапазона');
end = readDateInputIso(fillEndInput, 'Дата окончания диапазона');
} catch (error) {
showAlert('calendar-editor-alert', error.message, '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();
if (changed) markGridDirty();
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
}
function markGridDirty() {
gridDirtyScope.mark();
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку •';
}
function clearGridDirty() {
gridDirtyScope.clear();
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку';
}
function markSubjectDirty() {
subjectDirtyScope.mark();
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки •';
}
function clearSubjectDirty() {
subjectDirtyScope.clear();
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки';
}
function renderCalendarGrid(rows) {
activeCalendarDay = null;
selectedCalendarDayKey = null;
selectedCalendarDays = [];
const grouped = groupBy(rows, row => row.courseNumber);
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
${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 => `
${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
? ' | '
: `${week} | `).join('')}
${DAY_OPTIONS.map(day => `
| ${escapeHtml(day.shortLabel)} |
${weekSlots.map(week => {
if (week === null) {
return ' | ';
}
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
return row ? renderCalendarDay(row) : ' | ';
}).join('')}
`).join('')}
`;
}
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 || 'Т';
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) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = '';
return;
}
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
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) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = `
...
Выбрано ячеек: ${days.length}
Выберите код активности, чтобы применить его ко всему выделению
`;
return;
}
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
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 || '-';
applyActivityColorClass(day, activity);
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();
tooltip.innerHTML = renderCalendarDayTooltip(day);
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
applyActivityColorClass(tooltip, activity);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
function hideCalendarDayTooltip() {
calendarDayTooltip?.classList.remove('visible');
}
function positionCalendarDayTooltip(tooltip, day, event) {
const dayRect = day.getBoundingClientRect();
const pointerX = Number.isFinite(event?.clientX) ? event.clientX : dayRect.right;
const pointerY = Number.isFinite(event?.clientY) ? event.clientY : dayRect.bottom;
const tooltipRect = tooltip.getBoundingClientRect();
const { left, top } = calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth: tooltipRect.width,
tooltipHeight: tooltipRect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
calendarDayTooltipPositionAnimation?.cancel();
const positionedTransform = `translate3d(${left}px, ${top}px, 0) scale(1)`;
calendarDayTooltipPositionAnimation = tooltip.animate(
[
{ transform: positionedTransform },
{ transform: positionedTransform }
],
{ duration: 1, fill: 'forwards' }
);
}
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 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(day.dataset.course)}
Активность
${escapeHtml(activity?.name || 'Не задана')}
Время
${escapeHtml(timeLabel)}
`;
}
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 => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
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 : '-';
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
});
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, entry]) => {
const selectedClass = selectedCode === code ? ' is-selected' : '';
const suffix = selectedCode === code ? 'выбрано' : '';
return `${escapeHtml(code)}: ${entry.count}${suffix}`;
}).join('');
}
function populateYearSelects() {
const yearOptions = '' +
academicYears.map(year => ``).join('');
calendarYearSelect.innerHTML = yearOptions;
syncSelects(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 setCalendarSelection(select, id) {
const calendar = calendarCache.get(String(id));
select.innerHTML = calendar
? ``
: '';
calendarComboboxes.get(select)?.syncFromSelect();
}
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 calendarCache.get(String(editorCalendarSelect.value));
}
function selectedSubjectCalendar() {
return calendarCache.get(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 applyActivityColorClass(element, activity) {
if (!element) return;
Array.from(element.classList)
.filter(className => className.startsWith('calendar-activity-color-'))
.forEach(className => element.classList.remove(className));
if (activity) {
element.classList.add(activityColorClass(activity));
}
}
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 = specialityCode(speciality) || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code);
}
function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_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 = parseLocalIsoDate(value);
date.setDate(date.getDate() - 1);
return formatLocalDate(date);
}
function enumerateDates(startIso, endIso) {
const result = [];
const current = parseLocalIsoDate(startIso);
const end = parseLocalIsoDate(endIso);
while (current <= end) {
const iso = formatLocalDate(current);
const jsDay = current.getDay();
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
current.setDate(current.getDate() + 1);
}
return result;
}
function parseLocalIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(year, month - 1, day, 12);
if (!year || !month || !day || Number.isNaN(date.getTime())
|| date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
return date;
}
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 => {
calendarComboboxes.get(select)?.syncFromSelect();
if (select.customSelectInstance) {
select.customSelectInstance.rebuildMenu();
}
});
}
}