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' });
}