1216 lines
47 KiB
JavaScript
1216 lines
47 KiB
JavaScript
import { api, getSession } from '../api.js';
|
||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.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 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 semesterSelect = document.getElementById('schedule-view-semester');
|
||
|
||
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);
|
||
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('Нажмите «Показать», чтобы обновить расписание');
|
||
});
|
||
semesterSelect?.addEventListener('change', () => {
|
||
synchronizeDateWithSemester();
|
||
updatePeriodLabel();
|
||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||
});
|
||
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
|
||
resultNextButton?.addEventListener('click', () => moveResult(1));
|
||
tables?.addEventListener('click', event => {
|
||
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 {
|
||
state.dictionaries = await loadDictionaries(role, userDepartmentId);
|
||
selectCurrentSemester();
|
||
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);
|
||
}
|
||
|
||
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 = 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);
|
||
} 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 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 || '',
|
||
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 || 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>` : '';
|
||
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="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) {
|
||
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;
|
||
state.lessons = [];
|
||
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 || 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() {
|
||
if (!semesterSelect) return;
|
||
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value);
|
||
semesterSelect.value = currentSemester ? String(currentSemester.id) : '';
|
||
synchronizeDateWithSemester();
|
||
updatePeriodLabel();
|
||
}
|
||
|
||
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 createEmptyOverridePanel() {
|
||
return {
|
||
setRegistry() {},
|
||
openLesson() {},
|
||
openRegistry() {}
|
||
};
|
||
}
|
||
|
||
async function loadDictionaries(role, userDepartmentId) {
|
||
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = 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/schedule/semesters')
|
||
]);
|
||
|
||
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, 'Все типы');
|
||
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
|
||
|
||
return {
|
||
groups,
|
||
teachers,
|
||
classrooms,
|
||
departments,
|
||
semesters: 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, 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) {
|
||
const anchorDate = semester && !dateWithinSemester(selectedDate, semester)
|
||
? semester.startDate
|
||
: selectedDate;
|
||
return twoWeekRange(anchorDate);
|
||
}
|
||
|
||
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' });
|
||
}
|