import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; const ROLE_DEPARTMENT = 'DEPARTMENT'; 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 SPLIT_AUTO = 'auto'; const SPLIT_GROUPS = 'groups'; const SPLIT_TEACHERS = 'teachers'; const SPLIT_CLASSROOMS = 'classrooms'; export async function initScheduleView() { const role = localStorage.getItem('role'); const userDepartmentId = localStorage.getItem('departmentId'); const dateInput = document.getElementById('schedule-view-date'); const loadButton = document.getElementById('schedule-view-load'); const departmentField = document.getElementById('schedule-view-department-field'); const departmentSelect = document.getElementById('schedule-view-department'); const tables = document.getElementById('schedule-view-tables'); const countLabel = document.getElementById('schedule-view-count'); setDefaultDate(dateInput); loadButton?.addEventListener('click', loadData); try { await loadDictionaries(role, userDepartmentId); if (role === ROLE_DEPARTMENT) { departmentField.hidden = true; departmentSelect.value = userDepartmentId || ''; } await loadData(); } catch (error) { setTableState(error.message || 'Ошибка загрузки справочников'); } async function loadData() { hideAlert('schedule-view-alert'); setTableState('Загрузка...'); countLabel.textContent = 'Загрузка...'; if (!dateInput.value) { const message = 'Выберите дату в периоде'; showAlert('schedule-view-alert', message, 'error'); setTableState(message); countLabel.textContent = '0 занятий'; return; } const range = twoWeekRange(dateInput.value); const params = new URLSearchParams({ startDate: range.startDate, endDate: range.endDate }); addOptional(params, 'groupId', valueOf('schedule-view-group')); addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); addOptional(params, 'classroomId', valueOf('schedule-view-classroom')); addOptional(params, 'departmentId', role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value); 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); } catch (error) { const message = error.message || 'Ошибка загрузки расписания'; showAlert('schedule-view-alert', message, 'error'); setTableState(message); countLabel.textContent = '0 занятий'; } } function renderLessons(lessons, range) { countLabel.textContent = lessonCountLabel(lessons.length); if (!lessons.length) { setTableState('Занятия не найдены'); return; } const sections = buildScheduleSections(lessons); if (!sections.length) { setTableState('Нет данных для выбранного разреза таблиц'); return; } const tableCount = sections.reduce((count, section) => count + buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons).length, 0 ); if (!tableCount) { setTableState('Выберите корректный период'); return; } countLabel.textContent = `${lessonCountLabel(lessons.length)}, ${tableCountLabel(tableCount)}`; tables.innerHTML = sections .map(section => renderScheduleSection(section, range)) .join(''); } function renderScheduleSection(section, range) { const slots = collectSlots(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); return blocks .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) .join(''); } function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { const dayHeaders = DAY_ORDER.map(day => ` ${escapeHtml(dayShort(day))} ${escapeHtml(DAY_FULL_LABELS[day] || '')}
${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')}
`).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(block.label)}

${escapeHtml(`${lessonCountLabel(section.lessons.length)}; ${combinedWeekCaption(block)}`)}
${dayHeaders} ${rows}
Время
`; } function renderDayParityDate(date, label) { return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; } function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ parity, label, state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) })); if (slotStatesEqual(states[0].state, states[1].state)) { return `${renderUnifiedSlot(states[0].state)}`; } const halves = states.map(({ parity, label, state }) => renderSlotHalf(state, parity, label) ).join(''); return `${halves}`; } 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 renderLessonCard(lesson) { const subgroupText = (lesson.subgroupNames || []).join(', '); const meta = [ lesson.lessonTypeName, lesson.lessonFormat, PARITY_LABELS[lesson.parity] || lesson.parity ].filter(Boolean).join(' · '); return `
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
${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)}
`; } } 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') ]); fillSelect('schedule-view-group', groups, 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, 'Все типы'); if (role === ROLE_DEPARTMENT && userDepartmentId) { document.getElementById('schedule-view-department').value = userDepartmentId; } } 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 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) { const splitType = resolveSplitType(); const sections = new Map(); lessons.forEach(lesson => { splitEntitiesForLesson(lesson, splitType).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 resolveSplitType() { const requested = valueOf('schedule-view-split'); if ([SPLIT_GROUPS, SPLIT_TEACHERS, SPLIT_CLASSROOMS].includes(requested)) { return requested; } if (valueOf('schedule-view-teacher')) return SPLIT_TEACHERS; if (valueOf('schedule-view-classroom')) return SPLIT_CLASSROOMS; return SPLIT_GROUPS; } function splitEntitiesForLesson(lesson, splitType) { if (splitType === SPLIT_TEACHERS) { return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; } if (splitType === SPLIT_CLASSROOMS) { return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; } return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана'); } function singleEntity(prefix, id, name, fallback) { const title = name || fallback; return { key: `${prefix}:${id ?? title}`, title }; } function listEntities(prefix, ids = [], names = [], fallback) { const safeIds = Array.isArray(ids) ? ids : []; const safeNames = Array.isArray(names) ? names : []; if (!safeIds.length && !safeNames.length) { return [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; const name = 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 CELL_PARITY_LAYOUT .map(({ parity, label }) => { const dates = DAY_ORDER.map(day => block.datesByDay[day]?.[parity]).filter(Boolean); if (!dates.length) return `${label}: дат нет`; return `${label}: ${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`; }) .join('; '); } 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 tableCountLabel(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' }); }