import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.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)'; export async function initScheduleView() { const role = localStorage.getItem('role'); const userDepartmentId = normalizeId(localStorage.getItem('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 state = { dictionaries: { groups: [], teachers: [], classrooms: [], departments: [] }, sections: [], activeSectionIndex: 0, mobileDay: 1, range: null, totalLessons: 0 }; const mobileMedia = window.matchMedia(MOBILE_QUERY); setDefaultDate(dateInput); configureDepartmentTarget(); updatePeriodLabel(); updateEntityFieldVisibility(); renderResultNavigation(); targetSelect?.addEventListener('change', () => { clearInactiveEntityValues(); updateEntityFieldVisibility(); resetResults('Нажмите «Показать», чтобы обновить расписание'); }); 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('Нажмите «Показать», чтобы обновить расписание'); }); resultPreviousButton?.addEventListener('click', () => moveResult(-1)); resultNextButton?.addEventListener('click', () => moveResult(1)); if (typeof mobileMedia.addEventListener === 'function') { mobileMedia.addEventListener('change', renderActiveSection); } else if (typeof mobileMedia.addListener === 'function') { mobileMedia.addListener(renderActiveSection); } try { state.dictionaries = await loadDictionaries(role, userDepartmentId); configureDepartmentAccess(); updateEntityFieldVisibility(); await loadData(); } catch (error) { const message = error.message || 'Ошибка загрузки справочников'; showAlert('schedule-view-alert', message, 'error'); resetResults(message); } 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', ``); 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; 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 = twoWeekRange(dateInput.value); 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 = await api.get(`/api/schedule/search?${params}`); renderLessons(lessons, range, target, previousKey); } 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.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 selectedDepartmentId = role === ROLE_DEPARTMENT ? userDepartmentId : (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : ''); const allowedGroupIds = new Set(); if (selectedGroupId) { allowedGroupIds.add(selectedGroupId); } else if (selectedDepartmentId) { state.dictionaries.groups .filter(group => String(group.departmentId) === String(selectedDepartmentId)) .forEach(group => allowedGroupIds.add(String(group.id))); } 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 = 'Ничего не найдено'; return; } setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`); resultTabs.innerHTML = state.sections.map((item, index) => ` `).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 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 renderMobileDayTabs() { dayTabs.hidden = false; dayTabs.innerHTML = DAY_ORDER.map(day => ` `).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 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 renderMobileDayBlock(block, slots, lessonsByDateSlot) { const dayDates = block.datesByDay[state.mobileDay] || {}; const rows = slots.map(slot => { const states = slotStatesForDates(dayDates, slot, lessonsByDateSlot); return `
${escapeHtml(slot.orderLabel)} ${escapeHtml(slot.timeLabel)}
${renderSlotContent(states)}
`; }).join(''); return `
${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))} ${escapeHtml(mobileDayCaption(dayDates))}
${rows}
`; } function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { const dayHeaders = DAY_ORDER.map(day => ` ${escapeHtml(dayShort(day))} ${escapeHtml(DAY_FULL_LABELS[day] || '')} `).join(''); const rows = slots.map(slot => ` ${escapeHtml(slot.orderLabel)} ${escapeHtml(slot.timeLabel)} ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} ${DAY_ORDER.map(day => renderCombinedSlotCell(block.datesByDay[day] || {}, slot, lessonsByDateSlot)).join('')} `).join(''); return `

${escapeHtml(section.title)}

${escapeHtml(combinedWeekCaption(block))}
${dayHeaders} ${rows}
Время
`; } function renderDayParityDate(date, label) { return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; } function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { const states = slotStatesForDates(datesByParity, slot, lessonsByDateSlot); const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : ''; return `${renderSlotContent(states)}`; } function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ parity, label, state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) })); } 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 `
${state.lessons.map(renderLessonCard).join('')}
`; } if (state.kind === 'empty') { return '
Нет даты
'; } return '
Нет занятий
'; } function renderSlotHalf(state, parity, label) { if (state.kind === 'busy') { return `
${escapeHtml(label)} ${state.lessons.map(renderLessonCard).join('')}
`; } if (state.kind === 'empty') { return `
${escapeHtml(label)} Нет даты
`; } return `
${escapeHtml(label)} Нет занятий
`; } 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 || '', 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 || 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; if (startWeek === endWeek) { return `${startWeek} нед.`; } return `с ${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 ? `${escapeHtml(weeksText)}` : ''; return `
${escapeHtml(lesson.subjectName || 'Без дисциплины')} ${weeksBadge}
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''}
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
${escapeHtml(lesson.classroomName || 'Аудитория не указана')}
${meta ? `
${escapeHtml(meta)}
` : ''} ${lesson.scheduleRuleSlotId ? `
ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}
` : ''}
`; } function setTableState(message) { if (!tables) return; tables.innerHTML = `
${escapeHtml(message)}
`; } function resetResults(message) { state.sections = []; state.activeSectionIndex = 0; state.mobileDay = 1; state.totalLessons = 0; renderResultNavigation(); tableTitle.textContent = 'Расписание'; tableCaption.textContent = message; setTableState(message); countLabel.textContent = '0 занятий'; dayTabs.hidden = true; dayTabs.innerHTML = ''; } function updatePeriodLabel(range) { if (!periodLabel) return; if (!dateInput.value) { periodLabel.textContent = 'Период не выбран'; return; } periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value)); } 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); } } async function loadDictionaries(role, userDepartmentId) { const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([ api.get('/api/groups'), api.get('/api/users/teachers'), api.get('/api/classrooms'), api.get('/api/departments'), api.get('/api/subjects'), api.get('/api/lesson-types') ]); const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId ? groups.filter(group => String(group.departmentId) === String(userDepartmentId)) : groups; fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы'); fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели'); fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории'); 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 }; } 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 = `` + (items || []).map(item => ``).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)); } 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) { return 'Учебное расписание'; } 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' }); }