Files
magistr/frontend/admin/js/views/auditorium-workload.js

1015 lines
41 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, initMultiSelect, showAlert, hideAlert } from '../utils.js';
const NO_BUILDING = '__no_building__';
const DISPLAY_ALL = 'all';
const ROOM_PREFIX = 'room:';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
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 CAPACITY_BUCKETS = [
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
{ id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 },
{ id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 }
];
export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date');
const refreshButton = document.getElementById('workload-refresh');
const displaySelect = document.getElementById('workload-display-select');
const headerRow = document.getElementById('workload-header-row');
const tbody = document.getElementById('workload-tbody');
const summary = document.getElementById('workload-summary');
const overviewBlock = document.getElementById('workload-overview-block');
const roomBlock = document.getElementById('room-workload-block');
const roomSummary = document.getElementById('room-workload-summary');
const roomTables = document.getElementById('room-workload-tables');
const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter'));
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
let classrooms = [];
let timeSlots = [];
let equipments = [];
let groups = [];
let semesters = [];
let lessonsByRoomSlot = new Map();
let failedGroupLoads = 0;
let currentView = DISPLAY_ALL;
let selectedRoomId = null;
setDefaultDate();
initFilters();
bindEvents();
await loadInitialData();
function setDefaultDate() {
if (!dateInput || dateInput.value) return;
const today = new Date();
dateInput.value = formatIsoDate(today);
}
function initFilters() {
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
}
function bindEvents() {
refreshButton?.addEventListener('click', loadCurrentView);
dateInput?.addEventListener('change', loadCurrentView);
displaySelect?.addEventListener('change', () => {
applyDisplaySelection();
syncDisplayMode();
loadCurrentView();
});
[
['building-checkboxes', 'building-text', 'Все корпуса'],
['capacity-checkboxes', 'capacity-text', 'Любая вместимость'],
['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
].forEach(([containerId, textId, emptyText]) => {
const container = document.getElementById(containerId);
container?.addEventListener('change', () => {
updateFilterText(containerId, textId, emptyText);
if (currentView === DISPLAY_ALL) {
renderGrid();
}
});
});
}
async function loadInitialData() {
setTableLoading('Загрузка аудиторий и расписания...');
try {
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears] = await Promise.all([
api.get('/api/classrooms'),
api.get('/api/equipments'),
api.get('/api/groups'),
api.get('/api/admin/calendar/years').catch(() => [])
]);
classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name));
equipments = loadedEquipments;
groups = loadedGroups;
semesters = (academicYears || []).flatMap(year => year.semesters || []);
populateFilters();
populateDisplayOptions();
applyDisplaySelection();
syncDisplayMode();
await loadCurrentView();
} catch (error) {
const message = error.message || 'Ошибка загрузки данных загруженности аудиторий';
setTableError(message);
setRoomError(message);
}
}
async function loadCurrentView() {
applyDisplaySelection();
syncDisplayMode();
try {
if (currentView === DISPLAY_ALL) {
await loadScheduleForSelectedDate();
} else {
await loadRoomWorkload();
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности аудиторий';
if (currentView === DISPLAY_ALL) {
setTableError(message);
} else {
setRoomError(message);
}
}
}
async function loadScheduleForSelectedDate() {
const date = dateInput?.value;
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
return;
}
hideAlert('workload-alert');
failedGroupLoads = 0;
setTableLoading('Загрузка расписания на выбранную дату...');
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
if (!groups.length) {
lessonsByRoomSlot = new Map();
renderGrid();
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
return;
}
const responses = await Promise.allSettled(
groups.map(group => api.get(`/api/schedule?${scheduleParams(group, date, date)}`))
);
failedGroupLoads = responses.filter(response => response.status === 'rejected').length;
const lessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
lessonsByRoomSlot = groupLessonsByRoomSlot(lessons);
renderGrid();
if (failedGroupLoads > 0) {
showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error');
}
}
async function loadRoomWorkload() {
const date = dateInput?.value;
const room = selectedRoom();
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
setRoomError('Выберите дату для расчёта двухнедельного периода');
return;
}
if (!room) {
showAlert('workload-alert', 'Выберите аудиторию', 'error');
setRoomError('Выберите аудиторию в поле «Отображение»');
return;
}
hideAlert('workload-alert');
setRoomLoading('Загрузка расписания аудитории на две недели...');
const range = twoWeekRange(date);
const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates),
loadRoomLessons(range, room.id)
]);
renderRoomWorkload(room, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
failedSlotLoads: slotLoad.failedLoads,
failedGroupLoads: lessonLoad.failedLoads,
parityLessons: lessonLoad.parityLessons
});
const warnings = [];
if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`);
if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`);
if (warnings.length) {
showAlert('workload-alert', `Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`, 'error');
}
}
async function loadSlotsForDates(dates) {
const responses = await Promise.allSettled(
dates.map(date => api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`))
);
const slotsByDate = new Map();
responses.forEach((response, index) => {
const slots = response.status === 'fulfilled' ? response.value || [] : [];
slotsByDate.set(dates[index], [...slots].sort(compareSlots));
});
return {
slotsByDate,
failedLoads: responses.filter(response => response.status === 'rejected').length
};
}
async function loadRoomLessons(range, roomId) {
if (!groups.length) {
return {
lessons: [],
lessonsByDateSlot: new Map(),
parityLessons: [],
failedLoads: 0
};
}
const responses = await Promise.allSettled(
groups.map(group => api.get(`/api/schedule?${scheduleParams(group, range.startDate, range.endDate)}`))
);
const parityLessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
const lessons = uniqueLessons(
parityLessons.filter(lesson => sameId(lesson.classroomId, roomId))
);
const lessonsByDateSlot = new Map();
lessons.forEach(lesson => {
const slotKey = lessonSlotKey(lesson);
if (!lesson.date || slotKey == null) return;
const key = dateSlotKey(lesson.date, slotKey);
if (!lessonsByDateSlot.has(key)) lessonsByDateSlot.set(key, []);
lessonsByDateSlot.get(key).push(lesson);
});
lessonsByDateSlot.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
});
return {
lessons,
lessonsByDateSlot,
parityLessons,
failedLoads: responses.filter(response => response.status === 'rejected').length
};
}
function scheduleParams(group, startDate, endDate) {
return new URLSearchParams({
groupId: String(group.id),
startDate,
endDate
}).toString();
}
function populateFilters() {
renderCheckboxes(
'building-checkboxes',
buildingOptions(),
item => item.value,
item => item.label
);
renderCheckboxes(
'capacity-checkboxes',
CAPACITY_BUCKETS,
item => item.id,
item => item.name
);
renderCheckboxes(
'equipment-checkboxes',
equipmentOptions(),
item => item.id,
item => item.name
);
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
}
function populateDisplayOptions() {
if (!displaySelect) return;
const selectedValue = displaySelect.value;
displaySelect.innerHTML = '';
displaySelect.append(new Option('Все аудитории', DISPLAY_ALL));
classrooms.forEach(room => {
const details = [room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(', ');
const suffix = details ? ` · ${details}` : '';
displaySelect.append(new Option(`${room.name}${suffix}`, `${ROOM_PREFIX}${room.id}`));
});
if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) {
displaySelect.value = selectedValue;
}
}
function applyDisplaySelection() {
const value = displaySelect?.value || DISPLAY_ALL;
if (value.startsWith(ROOM_PREFIX)) {
currentView = 'room';
selectedRoomId = value.slice(ROOM_PREFIX.length);
return;
}
currentView = DISPLAY_ALL;
selectedRoomId = null;
}
function syncDisplayMode() {
const roomMode = currentView !== DISPLAY_ALL;
overviewFilters.forEach(filter => {
filter.hidden = roomMode;
});
if (overviewBlock) overviewBlock.hidden = roomMode;
if (roomBlock) roomBlock.hidden = !roomMode;
if (dateLabel) {
dateLabel.textContent = roomMode ? 'Дата в периоде' : 'Дата';
}
}
function selectedRoom() {
if (!selectedRoomId) return null;
return classrooms.find(room => sameId(room.id, selectedRoomId)) || null;
}
function buildingOptions() {
const values = new Map();
classrooms.forEach(room => {
const key = buildingKey(room);
values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building);
});
return Array.from(values.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => naturalCompare(a.label, b.label));
}
function equipmentOptions() {
const source = equipments.length
? equipments
: classrooms.flatMap(room => room.equipments || []);
const values = new Map();
source.forEach(item => {
if (item?.id && item?.name) values.set(String(item.id), item.name);
});
return Array.from(values.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => naturalCompare(a.name, b.name));
}
function renderCheckboxes(containerId, items, valueFn, labelFn) {
const container = document.getElementById(containerId);
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="workload-filter-empty">Нет данных</div>';
return;
}
container.innerHTML = items.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(valueFn(item))}">
<span class="checkmark"></span>
<span class="checkbox-label">${escapeHtml(labelFn(item))}</span>
</label>
`).join('');
}
function uniqueLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
if (!lesson.classroomId || !lesson.timeSlotId) return;
const key = [
lesson.date,
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
lesson.classroomId,
lesson.timeSlotId,
lesson.subjectId || '',
lesson.teacherId || ''
].join(':');
const existing = unique.get(key);
if (existing) {
existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames);
existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds);
existing.subgroupNames = mergeNames(existing.subgroupNames, lesson.subgroupNames);
existing.subgroupIds = mergeIds(existing.subgroupIds, lesson.subgroupIds);
return;
}
unique.set(key, {
...lesson,
groupNames: [...(lesson.groupNames || [])],
groupIds: [...(lesson.groupIds || [])],
subgroupNames: [...(lesson.subgroupNames || [])],
subgroupIds: [...(lesson.subgroupIds || [])]
});
});
return Array.from(unique.values());
}
function groupLessonsByRoomSlot(lessons) {
const grouped = new Map();
uniqueLessons(lessons).forEach(lesson => {
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
});
return grouped;
}
function renderGrid() {
const visibleClassrooms = filteredClassrooms();
renderHeader();
tbody.innerHTML = '';
if (!visibleClassrooms.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
renderSummary(0, 0, 0);
return;
}
visibleClassrooms.forEach(room => {
const tr = document.createElement('tr');
const roomCell = document.createElement('td');
roomCell.className = `axis-cell${room.isAvailable === false ? ' room-unavailable' : ''}`;
roomCell.innerHTML = `
<strong>${escapeHtml(room.name)}</strong>
<span>${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'}</span>
${room.building ? `<small>${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}</small>` : ''}
`;
tr.appendChild(roomCell);
timeSlots.forEach(slot => {
const td = document.createElement('td');
const lessons = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || [];
td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell';
td.innerHTML = lessons.length
? lessons.map(renderLessonCard).join('')
: '<span class="free-slot">Свободно</span>';
tr.appendChild(td);
});
tbody.appendChild(tr);
});
const occupiedCells = countOccupiedCells(visibleClassrooms);
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length);
}
function renderHeader() {
headerRow.innerHTML = `
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
</th>
`;
timeSlots.forEach(slot => {
const th = document.createElement('th');
th.innerHTML = `
<strong>${escapeHtml(slot.orderNumber || '')} пара</strong>
<span>${escapeHtml(timeSlotLabel(slot))}</span>
${slot.scopeApplyMode === 'DEFAULT' ? '' : `<small>${escapeHtml(scopeShort(slot))}</small>`}
`;
headerRow.appendChild(th);
});
}
function renderRoomWorkload(room, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
const slotRows = buildSlotRows(dates, slotsByDate, lessons);
const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons);
renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
if (!slotRows.length) {
roomTables.innerHTML = '<div class="loading-row room-workload-state">За выбранный период нет временных слотов</div>';
return;
}
roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot);
}
function renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
if (!roomSummary) return;
const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot);
const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot);
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const roomMeta = [
room.capacity ? `${room.capacity} мест` : null,
room.building || null,
room.floor ? `${room.floor} этаж` : null
].filter(Boolean).join(', ');
const warnings = [];
if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`);
if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`);
if (unknownParityDates) warnings.push('часть дат вне семестра');
roomSummary.innerHTML = `
<div class="room-summary-main">
<strong>${escapeHtml(room.name)}</strong>
${roomMeta ? `<span>${escapeHtml(roomMeta)}</span>` : ''}
</div>
<span class="room-summary-chip">${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))}</span>
<span class="room-summary-chip">Занято: ${occupiedCells} из ${totalCells} (${percent}%)</span>
${warnings.length ? `<span class="room-summary-warning">${escapeHtml(warnings.join(', '))}</span>` : ''}
`;
}
function renderRoomCombinedWeekTable(datesByDay, slotRows, slotsByDate, lessonsByDateSlot) {
const dayHeaders = DAY_ORDER.map(day => `
<th class="room-day-cell">
<strong>${escapeHtml(dayShort(day))}</strong>
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
<div class="room-day-parity-dates">
${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(datesByDay[day]?.[parity], label)).join('')}
</div>
</th>
`).join('');
const rows = slotRows.map(row => `
<tr>
<td class="room-time-cell">
<strong>${escapeHtml(row.orderLabel)}</strong>
<span>${escapeHtml(row.timeLabel)}</span>
</td>
${DAY_ORDER.map(day => renderRoomCombinedSlotCell(datesByDay[day] || {}, row, slotsByDate, lessonsByDateSlot)).join('')}
</tr>
`).join('');
return `
<section class="room-week-panel">
<div class="room-week-heading">
<h3>Нагрузка аудитории по неделям</h3>
<span>${escapeHtml(roomWeekCaption(datesByDay))}</span>
</div>
<div class="workload-grid-container room-grid-container">
<table class="workload-table room-week-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 renderRoomCombinedSlotCell(datesByParity, row, slotsByDate, lessonsByDateSlot) {
const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity,
label,
state: roomSlotState(datesByParity[parity], row, slotsByDate, lessonsByDateSlot)
}));
if (roomSlotStatesEqual(states[0].state, states[1].state)) {
return `<td class="room-combined-cell room-single-cell">${renderRoomUnifiedSlot(states[0].state)}</td>`;
}
const halves = states.map(({ parity, label, state }) =>
renderRoomSlotHalf(state, parity, label)
).join('');
return `<td class="room-combined-cell">${halves}</td>`;
}
function roomSlotState(date, row, slotsByDate, lessonsByDateSlot) {
if (!date) {
return { kind: 'empty', lessons: [] };
}
const key = dateSlotKey(date, row.key);
const lessons = lessonsByDateSlot.get(key) || [];
const slotExists = hasSlotForDate(date, row.key, slotsByDate) || lessons.length > 0;
if (lessons.length) {
return { kind: 'busy', lessons };
}
if (!slotExists) {
return { kind: 'missing', lessons: [] };
}
return { kind: 'free', lessons: [] };
}
function renderRoomUnifiedSlot(state) {
if (state.kind === 'busy') {
return `<div class="room-week-unified room-week-unified-busy">${state.lessons.map(renderLessonCard).join('')}</div>`;
}
if (state.kind === 'missing') {
return '<div class="room-week-unified room-week-unified-missing"><span class="slot-unavailable">Нет слота</span></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 renderRoomSlotHalf(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 === 'missing') {
return `
<div class="room-week-half room-week-half-${parity.toLowerCase()} room-week-half-missing">
<span class="room-half-marker">${escapeHtml(label)}</span>
<span class="slot-unavailable">Нет слота</span>
</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 roomSlotStatesEqual(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.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 buildSlotRows(dates, slotsByDate, lessons) {
const rows = new Map();
dates.forEach(date => {
(slotsByDate.get(date) || []).forEach(slot => {
const key = slotOrderKey(slot);
if (key == null) return;
putSlotRow(rows, key, slot.orderNumber, slot.startTime, slot.endTime);
});
});
lessons.forEach(lesson => {
const key = lessonSlotKey(lesson);
if (key == null) return;
putSlotRow(rows, key, lesson.timeSlotOrder, lesson.startTime, lesson.endTime);
});
return Array.from(rows.values()).sort(compareSlotRows);
}
function putSlotRow(rows, key, orderNumber, startTime, endTime) {
const existing = rows.get(key);
const next = {
key,
orderNumber,
startTime,
endTime,
orderLabel: orderNumber ? `${orderNumber} пара` : 'Пара',
timeLabel: startTime || endTime ? `${trimTime(startTime)}-${trimTime(endTime)}` : 'Время не указано'
};
if (!existing || existing.timeLabel === 'Время не указано') {
rows.set(key, next);
}
}
function buildDateGridByDay(dates, lessons) {
const datesByDay = {};
let unknownParityDates = 0;
dates.forEach((date, index) => {
let parity = parityForDate(date, lessons);
if (!parity) {
unknownParityDates += 1;
parity = index < 7 ? 'ODD' : 'EVEN';
}
const day = isoDay(date);
if (!datesByDay[day]) datesByDay[day] = {};
datesByDay[day][parity] = date;
});
return { datesByDay, unknownParityDates };
}
function parityForDate(date, lessons) {
const lessonParity = lessons.find(lesson => lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity))?.parity;
if (lessonParity) return lessonParity;
const weekNumber = weekNumberForDate(date);
if (!weekNumber) return null;
return weekNumber % 2 === 0 ? 'EVEN' : 'ODD';
}
function weekNumberForDate(date) {
const semester = semesters.find(item => item.startDate <= date && item.endDate >= date);
if (!semester) return null;
const days = Math.floor((parseIsoDate(date) - parseIsoDate(semester.startDate)) / MS_PER_DAY);
return Math.floor(days / 7) + 1;
}
function uniqueWeekNumbers(dates) {
return Array.from(new Set(dates.map(weekNumberForDate).filter(Boolean)))
.sort((a, b) => a - b);
}
function roomWeekCaption(datesByDay) {
return CELL_PARITY_LAYOUT
.map(({ parity, label }) => {
const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean);
const weeks = uniqueWeekNumbers(dates);
const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра';
const positionText = parity === 'ODD' ? 'сверху' : 'снизу';
return `${label} ${positionText}, ${weekText}`;
})
.join('; ');
}
function hasSlotForDate(date, key, slotsByDate) {
return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key);
}
function renderLessonCard(lesson) {
const subgroupText = (lesson.subgroupNames || []).join(', ');
return `
<div class="lesson-card">
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</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.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}</div>
</div>
`;
}
function filteredClassrooms() {
const selectedBuildings = selectedValues('building-checkboxes');
const selectedCapacities = selectedValues('capacity-checkboxes');
const selectedEquipment = selectedValues('equipment-checkboxes');
return classrooms.filter(room => {
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS
.filter(bucket => selectedCapacities.includes(bucket.id))
.some(bucket => bucket.match(room.capacity));
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
(room.equipments || []).some(item => String(item.id) === String(id))
);
return buildingMatches && capacityMatches && equipmentMatches;
});
}
function selectedValues(containerId) {
const container = document.getElementById(containerId);
if (!container) return [];
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => input.value);
}
function updateFilterText(containerId, textId, emptyText) {
const container = document.getElementById(containerId);
const text = document.getElementById(textId);
if (!container || !text) return;
const checked = Array.from(container.querySelectorAll('input[type="checkbox"]:checked'));
if (!checked.length) {
text.textContent = emptyText;
} else if (checked.length === 1) {
text.textContent = checked[0].closest('.checkbox-item')?.textContent.trim() || emptyText;
} else {
text.textContent = `Выбрано: ${checked.length}`;
}
}
function setTableLoading(message) {
renderHeader();
tbody.innerHTML = `<tr><td colspan="${Math.max(timeSlots.length, 1) + 1}" class="loading-row">${escapeHtml(message)}</td></tr>`;
if (summary) summary.textContent = '';
}
function setTableError(message) {
renderHeader();
tbody.innerHTML = `<tr><td colspan="${Math.max(timeSlots.length, 1) + 1}" class="loading-row">${escapeHtml(message)}</td></tr>`;
showAlert('workload-alert', message, 'error');
}
function setRoomLoading(message) {
if (roomSummary) roomSummary.textContent = '';
if (roomTables) roomTables.innerHTML = `<div class="loading-row room-workload-state">${escapeHtml(message)}</div>`;
}
function setRoomError(message) {
if (roomSummary) roomSummary.textContent = '';
if (roomTables) roomTables.innerHTML = `<div class="loading-row room-workload-state">${escapeHtml(message)}</div>`;
showAlert('workload-alert', message, 'error');
}
function renderSummary(roomCount, occupiedCells, totalCells) {
if (!summary) return;
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const date = dateInput?.value ? formatDate(dateInput.value) : '-';
const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-';
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : '';
summary.textContent = `Дата: ${date}, ${day}. Аудиторий: ${roomCount}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
}
function countOccupiedCells(rooms) {
return rooms.reduce((count, room) => count + timeSlots.filter(slot =>
(lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0
).length, 0);
}
function countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot) {
return dates.reduce((count, date) => {
const keys = new Set((slotsByDate.get(date) || []).map(slotOrderKey).filter(key => key != null));
lessonsByDateSlot.forEach((items, key) => {
if (items.length && key.startsWith(`${date}:`)) {
keys.add(key.slice(date.length + 1));
}
});
return count + keys.size;
}, 0);
}
function countOccupiedRoomCells(lessonsByDateSlot) {
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
}
function roomSlotKey(roomId, timeSlotId) {
return `${roomId}:${timeSlotId}`;
}
function dateSlotKey(date, slotKey) {
return `${date}:${slotKey}`;
}
function slotOrderKey(slot) {
const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id;
return value == null ? null : String(value);
}
function lessonSlotKey(lesson) {
const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId;
return value == null ? null : String(value);
}
function buildingKey(room) {
return room.building ? String(room.building) : NO_BUILDING;
}
function mergeNames(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b));
}
function mergeIds(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(id => id != null)));
}
function timeSlotLabel(slot) {
return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`;
}
function selectedIsoDay() {
if (!dateInput?.value) return null;
return isoDay(dateInput.value);
}
function scopeShort(slot) {
if (slot.scopeApplyMode === 'WEEKDAY') {
return dayShort(slot.scopeDayOfWeek);
}
return 'Ручн.';
}
function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
function isoDay(value) {
const day = parseIsoDate(value).getDay();
return day === 0 ? 7 : day;
}
function twoWeekRange(value) {
const selected = parseIsoDate(value);
const start = addDays(selected, 1 - isoDay(value));
const end = addDays(start, 13);
return {
startDate: formatIsoDate(start),
endDate: formatIsoDate(end)
};
}
function datesBetween(startDate, endDate) {
const dates = [];
for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) {
dates.push(formatIsoDate(date));
}
return dates;
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function trimTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function formatIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
function formatDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function compareSlots(a, b) {
return Number(a.orderNumber || 0) - Number(b.orderNumber || 0)
|| naturalCompare(a.startTime, b.startTime);
}
function compareSlotRows(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 sameId(first, second) {
return String(first) === String(second);
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
}