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

1087 lines
42 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 } 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)';
const DEFAULT_SEMESTER_WEEKS = 22;
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',
`<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;
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 = '<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 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 => `
<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 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 `
<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 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 `<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 || '',
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;
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
}
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
const semesterStartWeek = firstApplicableSemesterWeek(parity);
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
return startWeek === endWeek
? `(${startWeek} нед.)`
: `(с ${startWeek} по ${endWeek} нед.)`;
}
function semesterWeeksForLesson(lesson) {
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
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 dateInRange(date, startDate, endDate) {
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
}
function firstApplicableSemesterWeek(parity) {
return parity === 'EVEN' ? 2 : 1;
}
function lastApplicableSemesterWeek(maxWeeks, parity) {
const totalWeeks = Number(maxWeeks || 0);
if (!totalWeeks) return 0;
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
return totalWeeks;
}
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>` : '';
return `
<div class="lesson-card schedule-view-lesson-card">
<div class="lesson-subject" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
${weeksBadge}
</div>
<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>` : ''}
</div>
`;
}
function setTableState(message) {
if (!tables) return;
tables.innerHTML = `<div class="loading-row schedule-view-state">${escapeHtml(message)}</div>`;
}
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, academicYears] = 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'),
api.get('/api/admin/calendar/years').catch(() => [])
]);
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,
semesters: (academicYears || []).flatMap(year => year.semesters || [])
};
}
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));
}
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' });
}