1015 lines
41 KiB
JavaScript
1015 lines
41 KiB
JavaScript
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' });
|
||
}
|
||
}
|