Files
magistr/frontend/admin/js/views/schedule-view.js

1526 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api, getSession } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { preferredScheduleSemester } from '../schedule-period.js';
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
import { AsyncCombobox } from '../async-combobox.js';
import { readUrlState, writeUrlState } from '../url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
const TARGET_GROUPS = 'groups';
const TARGET_TEACHERS = 'teachers';
const TARGET_CLASSROOMS = 'classrooms';
const TARGET_DEPARTMENTS = 'departments';
const PARITY_LABELS = {
BOTH: 'каждая',
ODD: 'нечётная',
EVEN: 'чётная'
};
const DAY_SHORT_LABELS = {
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
};
const DAY_FULL_LABELS = {
1: 'Понедельник',
2: 'Вторник',
3: 'Среда',
4: 'Четверг',
5: 'Пятница',
6: 'Суббота',
7: 'Воскресенье'
};
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7];
const CELL_PARITY_LAYOUT = [
{ parity: 'ODD', label: 'Нечётная' },
{ parity: 'EVEN', label: 'Чётная' }
];
const MOBILE_QUERY = '(max-width: 760px)';
const DEFAULT_SEMESTER_WEEKS = 22;
const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание';
export async function initScheduleView() {
const session = getSession();
const role = session?.role;
const userDepartmentId = normalizeId(session?.departmentId);
const dateInput = document.getElementById('schedule-view-date');
const targetSelect = document.getElementById('schedule-view-target');
const loadButton = document.getElementById('schedule-view-load');
const previousPeriodButton = document.getElementById('schedule-view-prev-period');
const todayButton = document.getElementById('schedule-view-today');
const nextPeriodButton = document.getElementById('schedule-view-next-period');
const resultPreviousButton = document.getElementById('schedule-view-result-prev');
const resultNextButton = document.getElementById('schedule-view-result-next');
const resultTabs = document.getElementById('schedule-view-result-tabs');
const activeLabel = document.getElementById('schedule-view-active-label');
const countLabel = document.getElementById('schedule-view-count');
const periodLabel = document.getElementById('schedule-view-period-label');
const tableTitle = document.getElementById('schedule-view-table-title');
const tableCaption = document.getElementById('schedule-view-table-caption');
const dayTabs = document.getElementById('schedule-view-day-tabs');
const tables = document.getElementById('schedule-view-tables');
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
const departmentSelect = document.getElementById('schedule-view-department');
const academicYearSelect = document.getElementById('schedule-view-year');
const semesterSelect = document.getElementById('schedule-view-semester');
const restored = readUrlState({
scheduleTarget: TARGET_GROUPS,
scheduleEntity: '',
scheduleDate: '',
scheduleYear: '',
scheduleSemester: '',
scheduleSubject: '',
scheduleLessonType: '',
scheduleParity: ''
});
const state = {
dictionaries: {
groups: [],
teachers: [],
classrooms: [],
departments: []
},
sections: [],
activeSectionIndex: 0,
mobileDay: 1,
range: null,
totalLessons: 0,
lessons: [],
overrides: []
};
let overridePanel = createEmptyOverridePanel();
const mobileMedia = window.matchMedia(MOBILE_QUERY);
setDefaultDate(dateInput);
if (restored.scheduleDate) dateInput.value = restored.scheduleDate;
if (targetSelect && role !== ROLE_DEPARTMENT) targetSelect.value = restored.scheduleTarget;
restoreEntitySelect(restored.scheduleTarget, restored.scheduleEntity);
const entityComboboxes = setupEntityComboboxes(state.dictionaries, role, userDepartmentId);
configureDepartmentTarget();
updatePeriodLabel();
updateEntityFieldVisibility();
renderResultNavigation();
targetSelect?.addEventListener('change', () => {
clearInactiveEntityValues();
updateEntityFieldVisibility();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
loadButton?.addEventListener('click', loadData);
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
todayButton?.addEventListener('click', () => {
dateInput.value = toIsoDate(new Date());
loadData();
});
nextPeriodButton?.addEventListener('click', () => movePeriod(1));
dateInput?.addEventListener('change', () => {
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
academicYearSelect?.addEventListener('change', () => {
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, academicYearSelect.value);
const selected = resolveAcademicPeriod(yearSemesters, { date: dateInput.value });
renderPeriodSelects(academicYearSelect.value, selected.semesterId);
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1));
tables?.addEventListener('click', event => {
const reloadButton = event.target.closest('[data-schedule-view-reload]');
if (reloadButton) {
loadData();
return;
}
const button = event.target.closest('[data-schedule-override-edit]');
if (!button) return;
const lesson = state.lessons.find(item =>
String(item.scheduleRuleSlotId) === button.dataset.baseRuleSlotId
&& (item.originalLessonDate || item.date) === button.dataset.lessonDate
);
if (lesson) overridePanel.openLesson(lesson);
});
if (typeof mobileMedia.addEventListener === 'function') {
mobileMedia.addEventListener('change', renderActiveSection);
} else if (typeof mobileMedia.addListener === 'function') {
mobileMedia.addListener(renderActiveSection);
}
try {
Object.assign(state.dictionaries, await loadDictionaries(role, userDepartmentId));
mergeLoadedDictionaries(state.dictionaries, entityComboboxes);
restoreSimpleFilters(restored);
selectCurrentSemester(restored.scheduleYear, restored.scheduleSemester);
overridePanel = createScheduleOverridePanel({
role,
dictionaries: state.dictionaries,
getRange: () => state.range,
onChanged: loadData
});
configureDepartmentAccess();
updateEntityFieldVisibility();
await loadData();
} catch (error) {
const message = error.message || 'Ошибка загрузки справочников';
showAlert('schedule-view-alert', message, 'error');
resetResults(message);
}
['schedule-view-group', 'schedule-view-teacher', 'schedule-view-classroom', 'schedule-view-department',
'schedule-view-subject', 'schedule-view-lesson-type', 'schedule-view-parity'].forEach(id => {
document.getElementById(id)?.addEventListener('change', persistFilters);
});
function configureDepartmentTarget() {
if (role !== ROLE_DEPARTMENT) return;
targetSelect.value = TARGET_DEPARTMENTS;
targetSelect.disabled = true;
}
function configureDepartmentAccess() {
if (role !== ROLE_DEPARTMENT) return;
targetSelect.value = TARGET_DEPARTMENTS;
targetSelect.disabled = true;
if (departmentSelect) {
departmentSelect.value = userDepartmentId || '';
departmentSelect.disabled = true;
if (userDepartmentId && departmentSelect.value !== userDepartmentId) {
departmentSelect.insertAdjacentHTML('afterbegin',
`<option value="${escapeHtml(userDepartmentId)}">Своя кафедра</option>`);
departmentSelect.value = userDepartmentId;
}
}
}
function updateEntityFieldVisibility() {
const target = activeTarget();
entityFields.forEach(field => {
field.hidden = field.dataset.scheduleViewTargetField !== target;
});
}
function clearInactiveEntityValues() {
const target = activeTarget();
[
[TARGET_GROUPS, 'schedule-view-group'],
[TARGET_TEACHERS, 'schedule-view-teacher'],
[TARGET_CLASSROOMS, 'schedule-view-classroom'],
[TARGET_DEPARTMENTS, 'schedule-view-department']
].forEach(([fieldTarget, id]) => {
if (fieldTarget === target) return;
if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return;
if (entityComboboxes[fieldTarget]) {
entityComboboxes[fieldTarget].clear();
} else {
const select = document.getElementById(id);
if (select) select.value = '';
}
});
}
function activeTarget() {
if (role === ROLE_DEPARTMENT) return TARGET_DEPARTMENTS;
return targetSelect?.value || TARGET_GROUPS;
}
async function movePeriod(direction) {
if (!dateInput.value) {
dateInput.value = toIsoDate(new Date());
}
const range = twoWeekRange(dateInput.value);
const nextDate = addDays(parseIsoDate(range.startDate), direction * 14);
dateInput.value = toIsoDate(nextDate);
await loadData();
}
async function loadData() {
hideAlert('schedule-view-alert');
if (!dateInput.value) {
const message = 'Выберите дату в периоде';
showAlert('schedule-view-alert', message, 'error');
resetResults(message);
return;
}
if (role === ROLE_DEPARTMENT && !userDepartmentId) {
const message = 'Не удалось определить кафедру пользователя';
showAlert('schedule-view-alert', message, 'error');
resetResults(message);
return;
}
const target = activeTarget();
const range = scheduleRangeForSemester(dateInput.value, selectedSemester());
const previousKey = state.sections[state.activeSectionIndex]?.key;
state.range = range;
updatePeriodLabel(range);
setTableState('Загрузка...');
setActiveSummary('Загрузка расписаний...');
countLabel.textContent = 'Загрузка...';
resultTabs.innerHTML = '';
dayTabs.hidden = true;
dayTabs.innerHTML = '';
const params = new URLSearchParams({
startDate: range.startDate,
endDate: range.endDate
});
applyTargetFilter(params, target);
addOptional(params, 'subjectId', valueOf('schedule-view-subject'));
addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type'));
addOptional(params, 'parity', valueOf('schedule-view-parity'));
try {
const [lessons, overrides] = await Promise.all([
api.get(`/api/schedule/search?${params}`),
canEditScheduleOverrides(role)
? api.get(`/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`)
: Promise.resolve([])
]);
state.overrides = overrides;
overridePanel.setRegistry(overrides, range);
renderLessons(lessons, range, target, previousKey);
persistFilters();
trackUxEvent('расписание_найдено', { target, lessons: lessons.length });
} catch (error) {
const message = error.message || 'Ошибка загрузки расписания';
showAlert('schedule-view-alert', message, 'error');
resetResults(message);
}
}
function applyTargetFilter(params, target) {
if (role === ROLE_DEPARTMENT) {
params.set('departmentId', userDepartmentId);
return;
}
if (target === TARGET_GROUPS) {
addOptional(params, 'groupId', valueOf('schedule-view-group'));
return;
}
if (target === TARGET_TEACHERS) {
addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
return;
}
if (target === TARGET_CLASSROOMS) {
addOptional(params, 'classroomId', valueOf('schedule-view-classroom'));
return;
}
if (target === TARGET_DEPARTMENTS) {
addOptional(params, 'departmentId', valueOf('schedule-view-department'));
}
}
function renderLessons(lessons, range, target, previousKey) {
state.lessons = lessons;
state.totalLessons = lessons.length;
if (!lessons.length) {
state.sections = [];
state.activeSectionIndex = 0;
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = periodCaption(range);
setTableState('Занятия не найдены');
countLabel.textContent = '0 занятий';
return;
}
const sections = buildScheduleSections(lessons, splitTypeForTarget(target), buildSectionContext(target));
if (!sections.length) {
state.sections = [];
state.activeSectionIndex = 0;
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = periodCaption(range);
setTableState('Нет данных для выбранного режима просмотра');
countLabel.textContent = lessonCountLabel(lessons.length);
return;
}
state.sections = sections;
state.activeSectionIndex = Math.max(0, sections.findIndex(section => section.key === previousKey));
if (state.activeSectionIndex < 0) {
state.activeSectionIndex = 0;
}
state.mobileDay = preferredMobileDay(range);
renderResultNavigation();
renderActiveSection();
}
function buildSectionContext(target) {
const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : '';
const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId);
return {
selectedGroupId,
selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '',
allowedGroupIds,
groupNamesById: new Map(state.dictionaries.groups.map(group => [String(group.id), group.name]))
};
}
function splitTypeForTarget(target) {
if (target === TARGET_TEACHERS) return TARGET_TEACHERS;
if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS;
return TARGET_GROUPS;
}
function renderResultNavigation() {
const section = activeSection();
const totalSections = state.sections.length;
countLabel.textContent = state.totalLessons
? `${lessonCountLabel(state.totalLessons)}, ${sectionCountLabel(totalSections)}`
: '0 занятий';
resultPreviousButton.disabled = totalSections < 2;
resultNextButton.disabled = totalSections < 2;
if (!section) {
setActiveSummary('Нет выбранного расписания');
resultTabs.innerHTML = '<span class="schedule-view-empty-result">Ничего не найдено</span>';
return;
}
setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`);
resultTabs.innerHTML = state.sections.map((item, index) => `
<button type="button"
class="schedule-view-result-tab${index === state.activeSectionIndex ? ' active' : ''}"
data-schedule-view-section="${index}">
<span>${escapeHtml(item.title)}</span>
<small>${escapeHtml(lessonCountLabel(item.lessons.length))}</small>
</button>
`).join('');
resultTabs.querySelectorAll('[data-schedule-view-section]').forEach(button => {
button.addEventListener('click', () => {
state.activeSectionIndex = Number(button.dataset.scheduleViewSection);
state.mobileDay = preferredMobileDay(state.range);
renderResultNavigation();
renderActiveSection();
});
});
}
function setActiveSummary(text) {
activeLabel.textContent = text;
}
function moveResult(direction) {
if (state.sections.length < 2) return;
state.activeSectionIndex = (state.activeSectionIndex + direction + state.sections.length) % state.sections.length;
state.mobileDay = preferredMobileDay(state.range);
renderResultNavigation();
renderActiveSection();
}
function activeSection() {
return state.sections[state.activeSectionIndex] || null;
}
function renderActiveSection() {
const section = activeSection();
if (!section || !state.range) {
dayTabs.hidden = true;
dayTabs.innerHTML = '';
return;
}
tableTitle.textContent = section.title;
tableCaption.textContent = `${lessonCountLabel(section.lessons.length)} · ${periodCaption(state.range)}`;
if (mobileMedia.matches) {
renderMobileDayTabs();
renderMobileSchedule(section, state.range);
return;
}
dayTabs.hidden = true;
dayTabs.innerHTML = '';
renderDesktopSchedule(section, state.range);
}
function renderDesktopSchedule(section, range) {
const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderDesktopSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) {
setTableState('Занятия не найдены');
return;
}
tables.innerHTML = blocks
.map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section))
.join('');
}
function renderDesktopSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const dayHeaders = DAY_ORDER.map(day => `
<th class="room-day-cell">
<strong>${escapeHtml(dayShort(day))}</strong>
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
</th>
`).join('');
const rows = slots.map(slot => `
<tr>
<td class="room-time-cell">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
</td>
${DAY_ORDER.map(day => renderSemesterSlotCell(day, slot, lessonMatrix)).join('')}
</tr>
`).join('');
tables.innerHTML = `
<section class="schedule-view-week-panel schedule-view-semester-panel">
<div class="room-week-heading">
<h3>${escapeHtml(section.title)}</h3>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
<div class="workload-grid-container schedule-view-grid-container">
<table class="workload-table room-week-table schedule-view-matrix-table">
<thead>
<tr>
<th class="room-time-heading">Время</th>
${dayHeaders}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</section>
`;
}
function renderMobileDayTabs() {
dayTabs.hidden = false;
dayTabs.innerHTML = DAY_ORDER.map(day => `
<button type="button"
class="schedule-view-day-tab${day === state.mobileDay ? ' active' : ''}"
data-schedule-view-day="${day}">
${escapeHtml(dayShort(day))}
</button>
`).join('');
dayTabs.querySelectorAll('[data-schedule-view-day]').forEach(button => {
button.addEventListener('click', () => {
state.mobileDay = Number(button.dataset.scheduleViewDay);
renderActiveSection();
});
});
}
function renderMobileSchedule(section, range) {
const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderMobileSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) {
setTableState('Занятия не найдены');
return;
}
tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join('');
}
function renderMobileSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const rows = slots.map(slot => `
<article class="schedule-view-mobile-slot">
<div class="schedule-view-mobile-slot-time">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
</div>
<div class="schedule-view-mobile-slot-content">
${renderSlotContent(slotStatesForSemester(state.mobileDay, slot, lessonMatrix))}
</div>
</article>
`).join('');
tables.innerHTML = `
<section class="schedule-view-mobile-day schedule-view-mobile-semester">
<div class="schedule-view-mobile-day-heading">
<strong>${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))}</strong>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
${rows}
</section>
`;
}
function renderMobileDayBlock(block, slots, lessonsByDateSlot) {
const dayDates = block.datesByDay[state.mobileDay] || {};
const rows = slots.map(slot => {
const states = slotStatesForDates(dayDates, slot, lessonsByDateSlot);
return `
<article class="schedule-view-mobile-slot">
<div class="schedule-view-mobile-slot-time">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
</div>
<div class="schedule-view-mobile-slot-content">
${renderSlotContent(states)}
</div>
</article>
`;
}).join('');
return `
<section class="schedule-view-mobile-day">
<div class="schedule-view-mobile-day-heading">
<strong>${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))}</strong>
<span>${escapeHtml(mobileDayCaption(dayDates))}</span>
</div>
${rows}
</section>
`;
}
function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) {
const dayHeaders = DAY_ORDER.map(day => `
<th class="room-day-cell">
<strong>${escapeHtml(dayShort(day))}</strong>
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
</th>
`).join('');
const rows = slots.map(slot => `
<tr>
<td class="room-time-cell">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
</td>
${DAY_ORDER.map(day => renderCombinedSlotCell(block.datesByDay[day] || {}, slot, lessonsByDateSlot)).join('')}
</tr>
`).join('');
return `
<section class="schedule-view-week-panel">
<div class="room-week-heading">
<h3>${escapeHtml(section.title)}</h3>
<span>${escapeHtml(combinedWeekCaption(block))}</span>
</div>
<div class="workload-grid-container schedule-view-grid-container">
<table class="workload-table room-week-table schedule-view-matrix-table">
<thead>
<tr>
<th class="room-time-heading">Время</th>
${dayHeaders}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</section>
`;
}
function renderDayParityDate(date, label) {
return `<span>${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}</span>`;
}
function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) {
const states = slotStatesForDates(datesByParity, slot, lessonsByDateSlot);
const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : '';
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
}
function renderSemesterSlotCell(day, slot, lessonMatrix) {
const states = slotStatesForSemester(day, slot, lessonMatrix);
const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : '';
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
}
function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity,
label,
state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot)
}));
}
function slotStatesForSemester(day, slot, lessonMatrix) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
return {
parity,
label,
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
};
});
}
function buildSemesterLessonMatrix(lessons) {
const weekRanges = new Map();
lessons.forEach(lesson => {
const signature = lessonContentSignature(lesson);
const weekNumber = Number(lesson.weekNumber);
if (!Number.isFinite(weekNumber)) return;
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
range.startWeek = Math.min(range.startWeek, weekNumber);
range.endWeek = Math.max(range.endWeek, weekNumber);
weekRanges.set(signature, range);
});
const matrix = new Map();
lessons.forEach(lesson => {
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
? lesson.parity
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
const signature = lessonContentSignature(lesson);
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
if (!matrix.has(key)) matrix.set(key, new Map());
if (!matrix.get(key).has(signature)) {
const range = weekRanges.get(signature);
matrix.get(key).set(signature, {
...lesson,
displayStartWeek: range?.startWeek,
displayEndWeek: range?.endWeek
});
}
});
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
key,
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
]));
}
function renderSlotContent(states) {
if (slotStatesEqual(states[0].state, states[1].state)) {
return renderUnifiedSlot(states[0].state);
}
return states.map(({ parity, label, state }) =>
renderSlotHalf(state, parity, label)
).join('');
}
function scheduleSlotState(date, slot, lessonsByDateSlot) {
if (!date) {
return { kind: 'empty', lessons: [] };
}
const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || [];
if (lessons.length) {
return { kind: 'busy', lessons };
}
return { kind: 'free', lessons: [] };
}
function renderUnifiedSlot(state) {
if (state.kind === 'busy') {
return `<div class="room-week-unified room-week-unified-busy">${state.lessons.map(renderLessonCard).join('')}</div>`;
}
if (state.kind === 'empty') {
return '<div class="room-week-unified room-week-unified-empty"><span class="slot-unavailable">Нет даты</span></div>';
}
return '<div class="room-week-unified room-week-unified-free"><span class="free-slot">Нет занятий</span></div>';
}
function renderSlotHalf(state, parity, label) {
if (state.kind === 'busy') {
return `
<div class="room-week-half room-week-half-${parity.toLowerCase()} room-week-half-busy">
<span class="room-half-marker">${escapeHtml(label)}</span>
${state.lessons.map(renderLessonCard).join('')}
</div>
`;
}
if (state.kind === 'empty') {
return `
<div class="room-week-half room-week-half-${parity.toLowerCase()} room-week-half-empty">
<span class="room-half-marker">${escapeHtml(label)}</span>
<span class="slot-unavailable">Нет даты</span>
</div>
`;
}
return `
<div class="room-week-half room-week-half-${parity.toLowerCase()} room-week-half-free">
<span class="room-half-marker">${escapeHtml(label)}</span>
<span class="free-slot">Нет занятий</span>
</div>
`;
}
function slotStatesEqual(first, second) {
if (first.kind !== second.kind) return false;
if (first.kind !== 'busy') return true;
return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
}
function lessonSetSignature(lessons) {
return lessons
.map(lessonContentSignature)
.sort((a, b) => naturalCompare(a, b))
.join('|');
}
function lessonContentSignature(lesson) {
return [
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
lesson.subjectId || lesson.subjectName || '',
lesson.teacherId || lesson.teacherName || '',
lesson.classroomId || lesson.classroomName || '',
lesson.lessonTypeId || lesson.lessonTypeName || '',
lesson.lessonFormat || '',
lesson.scheduleOverrideId || '',
lesson.overrideAction || '',
lesson.originalLessonDate || '',
valueListSignature(lesson.groupIds, lesson.groupNames),
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
].join(':');
}
function valueListSignature(primary = [], fallback = []) {
const primaryList = Array.isArray(primary) ? primary : [];
const fallbackList = Array.isArray(fallback) ? fallback : [];
const source = primaryList.length ? primaryList : fallbackList;
return [...source]
.map(value => String(value))
.sort((a, b) => naturalCompare(a, b))
.join(',');
}
function getLessonWeeksText(lesson) {
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
}
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
return '';
}
const totalHours = lesson.lessonTypeAcademicHours;
const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0;
// Каждое занятие - это 2 часа
const totalLessons = Math.ceil(totalHours / 2);
if (totalLessons <= 0) return '';
const parity = lesson.ruleParity || lesson.parity;
let startWeek, endWeek;
if (parity === 'EVEN' || parity === 'ODD') {
// Раз в две недели
startWeek = lesson.weekNumber - consumedBefore;
endWeek = startWeek + (totalLessons - 1) * 2;
} else {
// Каждую неделю
const steps = Math.floor(consumedBefore / 2);
startWeek = lesson.weekNumber - steps;
endWeek = startWeek + totalLessons - 1;
}
if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek;
return formatLessonWeeksText(startWeek, endWeek);
}
function renderLessonCard(lesson) {
const subgroupText = (lesson.subgroupNames || []).join(', ');
const meta = [
lesson.lessonTypeName,
lesson.lessonFormat,
PARITY_LABELS[lesson.parity] || lesson.parity
].filter(Boolean).join(' · ');
const weeksText = getLessonWeeksText(lesson);
const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : '';
const sourceDate = lesson.originalLessonDate || lesson.date;
const overrideBadge = lesson.scheduleOverrideId
? '<span class="schedule-override-badge">Разовая правка</span>'
: '';
const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate
? `
<button type="button"
class="btn btn-sm btn-secondary schedule-override-edit"
data-schedule-override-edit
data-base-rule-slot-id="${escapeHtml(lesson.scheduleRuleSlotId)}"
data-lesson-date="${escapeHtml(sourceDate)}">
Изменить
</button>
`
: '';
return `
<div class="lesson-card schedule-view-lesson-card">
<div class="lesson-subject schedule-view-lesson-title" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
${weeksBadge}
</div>
${overrideBadge}
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
<div class="lesson-meta">${escapeHtml(lesson.classroomName || 'Аудитория не указана')}</div>
${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''}
${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''}
${editButton}
</div>
`;
}
function setTableState(message, showReloadButton = false) {
if (!tables) return;
tables.innerHTML = `
<div class="loading-row schedule-view-state${showReloadButton ? ' schedule-view-state-actionable' : ''}">
<span>${escapeHtml(message)}</span>
${showReloadButton ? `
<button type="button"
class="btn btn-md btn-primary schedule-view-state-reload"
data-schedule-view-reload>
Показать расписание
</button>
` : ''}
</div>
`;
}
function resetResults(message) {
state.sections = [];
state.activeSectionIndex = 0;
state.mobileDay = 1;
state.totalLessons = 0;
state.lessons = [];
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = message;
setTableState(message, message === SCHEDULE_REFRESH_PROMPT);
countLabel.textContent = '0 занятий';
dayTabs.hidden = true;
dayTabs.innerHTML = '';
}
function updatePeriodLabel(range) {
if (!periodLabel) return;
if (!dateInput.value) {
periodLabel.textContent = 'Период не выбран';
return;
}
periodLabel.textContent = periodCaption(
range || scheduleRangeForSemester(dateInput.value, selectedSemester()),
selectedSemester()
);
}
function selectedSemester() {
const semesterId = semesterSelect?.value;
if (!semesterId) return null;
return (state.dictionaries.semesters || [])
.find(semester => String(semester.id) === String(semesterId)) || null;
}
function selectCurrentSemester(academicYearId = '', semesterId = '') {
if (!academicYearSelect || !semesterSelect) return;
const selected = resolveAcademicPeriod(state.dictionaries.semesters, {
academicYearId,
semesterId,
date: dateInput.value
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
synchronizeDateWithSemester();
updatePeriodLabel();
}
function renderPeriodSelects(academicYearId, semesterId) {
const years = academicYearsFromSemesters(state.dictionaries.semesters);
const selectedYearId = String(academicYearId || years[0]?.id || '');
academicYearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
academicYearSelect.value = selectedYearId;
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, selectedYearId);
semesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterOptionLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| preferredScheduleSemester(yearSemesters, dateInput.value)
|| yearSemesters[0];
semesterSelect.value = String(selectedSemester?.id || '');
}
function synchronizeDateWithSemester() {
const semester = selectedSemester();
if (!semester) {
dateInput.value = toIsoDate(new Date());
return;
}
if (!dateWithinSemester(dateInput.value, semester)) {
dateInput.value = semester.startDate;
}
}
function groupNameById(id) {
return state.dictionaries.groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
}
function preferredMobileDay(range) {
if (!range) return 1;
return isoDay(dateInput.value || range.startDate);
}
function persistFilters() {
const target = activeTarget();
const entitySelectId = {
groups: 'schedule-view-group',
teachers: 'schedule-view-teacher',
classrooms: 'schedule-view-classroom',
departments: 'schedule-view-department'
}[target];
writeUrlState({
scheduleTarget: role === ROLE_DEPARTMENT ? '' : target,
scheduleEntity: entitySelectId ? valueOf(entitySelectId) : '',
scheduleDate: dateInput.value,
scheduleYear: academicYearSelect?.value || '',
scheduleSemester: semesterSelect?.value || '',
scheduleSubject: valueOf('schedule-view-subject'),
scheduleLessonType: valueOf('schedule-view-lesson-type'),
scheduleParity: valueOf('schedule-view-parity')
});
}
}
function createEmptyOverridePanel() {
return {
setRegistry() {},
openLesson() {},
openRegistry() {}
};
}
async function loadDictionaries(role, userDepartmentId) {
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/departments'),
api.get('/api/subjects'),
api.get('/api/lesson-types'),
api.get('/api/schedule/semesters')
]);
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
return {
groups: [],
teachers: [],
classrooms: [],
departments,
semesters: semesters || []
};
}
function setupEntityComboboxes(dictionaries, role, userDepartmentId) {
const definitions = {
groups: {
select: document.getElementById('schedule-view-group'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
if (role === ROLE_DEPARTMENT && userDepartmentId) params.set('departmentId', userDepartmentId);
return api.get(`/api/groups/options?${params}`);
},
label: item => item.name,
description: item => [item.specialtyCode, item.specialtyProfileName, item.educationFormName].filter(Boolean).join(' · ')
},
teachers: {
select: document.getElementById('schedule-view-teacher'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/users/teachers/options?${params}`);
},
label: item => item.fullName || item.username,
description: item => [item.jobTitle, item.departmentName].filter(Boolean).join(' · ')
},
classrooms: {
select: document.getElementById('schedule-view-classroom'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/classrooms/options?${params}`);
},
label: roomLabel,
description: item => [item.capacity ? `${item.capacity} мест` : null, item.building].filter(Boolean).join(' · ')
}
};
return Object.fromEntries(Object.entries(definitions).map(([key, definition]) => {
const combo = new AsyncCombobox(definition.select, {
placeholder: `Найдите ${key === 'groups' ? 'группу' : key === 'teachers' ? 'преподавателя' : 'аудиторию'}`,
optionLabel: definition.label,
optionDescription: definition.description,
load: async (query, selectedId) => {
const items = await definition.load(query, selectedId);
mergeItems(dictionaries, key, items);
return items;
}
});
combo.preload('');
return [key, combo];
}));
}
function mergeLoadedDictionaries(dictionaries, combos) {
Object.keys(combos).forEach(key => mergeItems(dictionaries, key, combos[key].items || []));
}
function mergeItems(dictionaries, key, items) {
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
(items || []).forEach(item => merged.set(String(item.id), item));
dictionaries[key] = [...merged.values()];
}
function restoreEntitySelect(target, value) {
if (!value) return;
const id = {
groups: 'schedule-view-group',
teachers: 'schedule-view-teacher',
classrooms: 'schedule-view-classroom',
departments: 'schedule-view-department'
}[target];
const select = document.getElementById(id);
if (select) select.innerHTML = `<option value="${escapeHtml(value)}" selected>Загрузка...</option>`;
}
function restoreSimpleFilters(restored) {
[
['schedule-view-subject', restored.scheduleSubject],
['schedule-view-lesson-type', restored.scheduleLessonType],
['schedule-view-parity', restored.scheduleParity]
].forEach(([id, value]) => {
const select = document.getElementById(id);
if (select && value && Array.from(select.options).some(option => option.value === String(value))) {
select.value = String(value);
}
});
}
function setDefaultDate(dateInput) {
const today = new Date();
dateInput.value = toIsoDate(today);
}
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
const select = document.getElementById(id);
if (!select) return;
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
}
function addOptional(params, key, value) {
if (value) params.set(key, value);
}
function valueOf(id) {
return document.getElementById(id)?.value || '';
}
function normalizeId(value) {
if (!value || value === 'null' || value === 'undefined') return '';
return String(value);
}
function roomLabel(room) {
return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · ');
}
function collectSlots(lessons) {
const slots = new Map();
lessons.forEach(lesson => {
const key = lessonSlotKey(lesson);
const existing = slots.get(key);
const next = {
key,
id: lesson.timeSlotId,
orderNumber: lesson.timeSlotOrder,
startTime: lesson.startTime,
endTime: lesson.endTime,
orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара',
timeLabel: lesson.startTime || lesson.endTime
? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}`
: 'Время не указано'
};
if (!existing || existing.timeLabel === 'Время не указано') {
slots.set(key, next);
}
});
return Array.from(slots.values()).sort(compareSlots);
}
function groupLessonsByDateSlot(lessons) {
const grouped = new Map();
lessons.forEach(lesson => {
if (!lesson.date) return;
const key = dateSlotKey(lesson.date, lessonSlotKey(lesson));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)
|| naturalCompare(a.teacherName, b.teacherName)
|| naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(',')));
});
return grouped;
}
function buildScheduleSections(lessons, splitType, context) {
const sections = new Map();
lessons.forEach(lesson => {
splitEntitiesForLesson(lesson, splitType, context).forEach(entity => {
if (!sections.has(entity.key)) {
sections.set(entity.key, {
key: entity.key,
title: entity.title,
lessons: []
});
}
sections.get(entity.key).lessons.push(lesson);
});
});
return Array.from(sections.values())
.map(section => ({
...section,
lessons: sortLessons(section.lessons)
}))
.sort((a, b) => naturalCompare(a.title, b.title));
}
export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) {
return target === TARGET_GROUPS && selectedGroupId
? new Set([String(selectedGroupId)])
: new Set();
}
function splitEntitiesForLesson(lesson, splitType, context) {
if (splitType === TARGET_TEACHERS) {
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
}
if (splitType === TARGET_CLASSROOMS) {
return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')];
}
if (context.selectedGroupId) {
return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')];
}
return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById);
}
function singleEntity(prefix, id, name, fallback) {
const title = name || fallback;
return {
id,
key: `${prefix}:${id ?? title}`,
title
};
}
function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) {
const safeIds = Array.isArray(ids) ? ids : [];
const safeNames = Array.isArray(names) ? names : [];
if (!safeIds.length && !safeNames.length) {
return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)];
}
const maxLength = Math.max(safeIds.length, safeNames.length);
const entities = [];
for (let index = 0; index < maxLength; index += 1) {
const id = safeIds[index] ?? null;
if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) {
continue;
}
const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`);
entities.push(singleEntity(prefix, id, name, fallback));
}
return entities;
}
function sortLessons(lessons) {
return [...lessons].sort((a, b) =>
naturalCompare(a.date, b.date)
|| compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b))
|| naturalCompare(a.subjectName, b.subjectName)
|| naturalCompare(a.teacherName, b.teacherName)
|| naturalCompare(a.classroomName, b.classroomName)
);
}
function lessonSlotDescriptor(lesson) {
return {
key: lessonSlotKey(lesson),
orderNumber: lesson.timeSlotOrder,
startTime: lesson.startTime
};
}
function buildCombinedWeekBlocks(startDate, endDate, lessons) {
const dates = datesBetween(startDate, endDate);
const weekGroups = new Map();
dates.forEach(date => {
const key = weekStart(date);
if (!weekGroups.has(key)) weekGroups.set(key, []);
weekGroups.get(key).push(date);
});
const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b));
const blocks = [];
for (let index = 0; index < weekStarts.length; index += 2) {
const firstWeek = weekGroups.get(weekStarts[index]) || [];
const secondWeek = weekGroups.get(weekStarts[index + 1]) || [];
const blockDates = [...firstWeek, ...secondWeek];
const firstParity = weekParity(firstWeek, lessons) || 'ODD';
const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD');
const datesWithFallback = [
...firstWeek.map(date => ({ date, fallbackParity: firstParity })),
...secondWeek.map(date => ({ date, fallbackParity: secondParity }))
];
blocks.push({
startDate: blockDates[0],
endDate: blockDates[blockDates.length - 1],
datesByDay: buildDatesByDay(datesWithFallback, lessons),
label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}`
});
}
return blocks;
}
function weekParity(dates, lessons) {
for (const date of dates) {
const parity = parityForDate(date, lessons);
if (parity) return parity;
}
return null;
}
function buildDatesByDay(datesWithFallback, lessons) {
const datesByDay = {};
datesWithFallback.forEach(({ date, fallbackParity }) => {
const day = isoDay(date);
const parity = parityForDate(date, lessons) || fallbackParity;
if (!datesByDay[day]) datesByDay[day] = {};
datesByDay[day][parity] = date;
});
return datesByDay;
}
function parityForDate(date, lessons) {
const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity));
if (lesson?.parity) return lesson.parity;
const withWeek = lessons.find(item => item.date === date && item.weekNumber != null);
if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD';
return null;
}
function combinedWeekCaption(block) {
return 'Нечётная и чётная недели';
}
function mobileDayCaption(datesByParity) {
return 'Нечётная / Чётная';
}
function periodCaption(range, semester = null) {
if (!range) return 'Учебное расписание';
const rangeLabel = `${formatDate(range.startDate)} — ${formatDate(range.endDate)}`;
return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel;
}
export function semesterOptionLabel(semester) {
const type = String(semester?.semesterType || '').toLowerCase();
const typeLabel = type === 'autumn'
? 'Осенний семестр'
: (type === 'spring' ? 'Весенний семестр' : 'Семестр');
const academicYear = semester?.academicYearTitle || 'Учебный год';
const dates = semester?.startDate && semester?.endDate
? ` · ${formatDate(semester.startDate)} — ${formatDate(semester.endDate)}`
: '';
return `${academicYear} · ${typeLabel}${dates}`;
}
export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
}
export function scheduleRangeForSemester(selectedDate, semester) {
if (semester?.startDate && semester?.endDate) {
return {
startDate: semester.startDate,
endDate: semester.endDate
};
}
return twoWeekRange(selectedDate);
}
export function formatLessonWeeksText(startWeek, endWeek) {
const start = Number(startWeek);
const end = Number(endWeek);
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
return start === end
? `(${start} нед.)`
: `(с ${start} по ${end} нед.)`;
}
function semesterScheduleCaption(semester) {
const weeks = semesterWeekCount(semester);
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
return `Весь семестр · ${weekLabel}`;
}
function semesterWeekCount(semester) {
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
function dateWithinSemester(date, semester) {
return Boolean(
date
&& semester?.startDate
&& semester?.endDate
&& date >= semester.startDate
&& date <= semester.endDate
);
}
function lessonSlotKey(lesson) {
const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`;
return value ? String(value) : 'unknown';
}
function dateSlotKey(date, slotKey) {
return `${date}:${slotKey}`;
}
function weekStart(value) {
const date = parseIsoDate(value);
const day = date.getDay() || 7;
date.setDate(date.getDate() + 1 - day);
return toIsoDate(date);
}
function twoWeekRange(value) {
const selected = parseIsoDate(value);
const start = addDays(selected, 1 - isoDay(value));
const end = addDays(start, 13);
return {
startDate: toIsoDate(start),
endDate: toIsoDate(end)
};
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function datesBetween(startDate, endDate) {
const dates = [];
for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) {
dates.push(toIsoDate(date));
}
return dates;
}
function isoDay(value) {
const day = parseIsoDate(value).getDay();
return day === 0 ? 7 : day;
}
function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
function formatDate(value) {
if (!value) return '-';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
function lessonCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} занятий`;
if (count % 10 === 1) return `${count} занятие`;
if ([2, 3, 4].includes(count % 10)) return `${count} занятия`;
return `${count} занятий`;
}
function sectionCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} расписаний`;
if (count % 10 === 1) return `${count} расписание`;
if ([2, 3, 4].includes(count % 10)) return `${count} расписания`;
return `${count} расписаний`;
}
function compareSlots(a, b) {
const first = Number(a.orderNumber || a.key);
const second = Number(b.orderNumber || b.key);
if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
return first - second;
}
return naturalCompare(a.startTime, b.startTime)
|| naturalCompare(a.key, b.key);
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}