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

1173 lines
47 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 TARGET_CLASSROOMS = 'classrooms';
const TARGET_TEACHERS = 'teachers';
const TARGET_DEPARTMENTS = 'departments';
const ROOM_PREFIX = 'room:';
const TEACHER_PREFIX = 'teacher:';
const DEPARTMENT_PREFIX = 'department:';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const WORKLOAD_TARGETS = {
[TARGET_CLASSROOMS]: {
allLabel: 'Все аудитории',
emptyLabel: 'Нет аудиторий по выбранным фильтрам',
rowLabel: 'Аудитория',
summaryLabel: 'Аудиторий',
periodTitle: 'Нагрузка аудитории по неделям',
selectError: 'Выберите аудиторию',
prefix: ROOM_PREFIX
},
[TARGET_TEACHERS]: {
allLabel: 'Все преподаватели',
emptyLabel: 'Нет преподавателей',
rowLabel: 'Преподаватель',
summaryLabel: 'Преподавателей',
periodTitle: 'Нагрузка преподавателя по неделям',
selectError: 'Выберите преподавателя',
prefix: TEACHER_PREFIX
},
[TARGET_DEPARTMENTS]: {
allLabel: 'Все кафедры',
emptyLabel: 'Нет кафедр',
rowLabel: 'Кафедра',
summaryLabel: 'Кафедр',
periodTitle: 'Нагрузка кафедры по неделям',
selectError: 'Выберите кафедру',
prefix: DEPARTMENT_PREFIX
}
};
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 targetSelect = document.getElementById('workload-target-select');
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 teachers = [];
let departments = [];
let timeSlots = [];
let equipments = [];
let groups = [];
let semesters = [];
let lessonsByEntitySlot = new Map();
let failedGroupLoads = 0;
let currentTarget = TARGET_CLASSROOMS;
let currentView = DISPLAY_ALL;
let selectedRoomId = null;
let selectedEntityId = 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);
targetSelect?.addEventListener('change', () => {
currentTarget = targetSelect.value || TARGET_CLASSROOMS;
currentView = DISPLAY_ALL;
selectedRoomId = null;
selectedEntityId = null;
populateDisplayOptions();
applyDisplaySelection();
syncDisplayMode();
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, loadedTeachers, loadedDepartments] = await Promise.all([
api.get('/api/classrooms'),
api.get('/api/equipments'),
api.get('/api/groups'),
api.get('/api/admin/calendar/years').catch(() => []),
api.get('/api/users/teachers'),
api.get('/api/departments')
]);
classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name));
teachers = [...loadedTeachers].sort((a, b) => naturalCompare(userName(a), userName(b)));
departments = [...loadedDepartments].sort((a, b) => naturalCompare(departmentName(a), departmentName(b)));
equipments = loadedEquipments;
groups = loadedGroups;
semesters = (academicYears || []).flatMap(year => year.semesters || []);
currentTarget = targetSelect?.value || TARGET_CLASSROOMS;
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 loadSelectedEntityWorkload();
}
} 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) {
lessonsByEntitySlot = 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 || []);
lessonsByEntitySlot = groupLessonsByEntitySlot(lessons);
renderGrid();
if (failedGroupLoads > 0) {
showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error');
}
}
async function loadSelectedEntityWorkload() {
const date = dateInput?.value;
const entity = selectedEntity();
const target = targetConfig();
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
setRoomError('Выберите дату для расчёта двухнедельного периода');
return;
}
if (!entity) {
showAlert('workload-alert', target.selectError, 'error');
setRoomError(`${target.selectError} в поле «Отображение»`);
return;
}
hideAlert('workload-alert');
setRoomLoading('Загрузка расписания на две недели...');
const range = twoWeekRange(date);
const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates),
loadEntityLessons(range, entity.id)
]);
renderEntityWorkload(entity, 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 loadEntityLessons(range, entityId) {
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 => entityIdsForLesson(lesson).some(id => sameId(id, entityId)));
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;
const target = targetConfig();
displaySelect.innerHTML = '';
displaySelect.append(new Option(target.allLabel, DISPLAY_ALL));
allEntities().forEach(entity => {
displaySelect.append(new Option(entityOptionLabel(entity), `${target.prefix}${entity.id}`));
});
if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) {
displaySelect.value = selectedValue;
}
}
function applyDisplaySelection() {
const value = displaySelect?.value || DISPLAY_ALL;
selectedRoomId = null;
selectedEntityId = null;
const target = targetConfig();
if (value.startsWith(target.prefix)) {
currentView = 'detail';
selectedEntityId = value.slice(target.prefix.length);
if (currentTarget === TARGET_CLASSROOMS) {
selectedRoomId = selectedEntityId;
}
return;
}
currentView = DISPLAY_ALL;
}
function syncDisplayMode() {
const roomMode = currentView !== DISPLAY_ALL;
overviewFilters.forEach(filter => {
filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS;
});
if (overviewBlock) overviewBlock.hidden = roomMode;
if (roomBlock) roomBlock.hidden = !roomMode;
if (dateLabel) {
dateLabel.textContent = roomMode ? 'Дата в периоде' : 'Дата';
}
}
function selectedEntity() {
const id = selectedEntityId || selectedRoomId;
if (!id) return null;
return allEntities().find(entity => sameId(entity.id, id)) || null;
}
function targetConfig() {
return WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
}
function allEntities() {
if (currentTarget === TARGET_TEACHERS) return teachers;
if (currentTarget === TARGET_DEPARTMENTS) return departments;
return classrooms;
}
function visibleEntities() {
const source = currentTarget === TARGET_CLASSROOMS ? filteredClassrooms() : allEntities();
if (!selectedEntityId) return source;
return source.filter(entity => sameId(entity.id, selectedEntityId));
}
function entityOptionLabel(entity) {
if (currentTarget === TARGET_TEACHERS) {
const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
return details ? `${userName(entity)} · ${details}` : userName(entity);
}
if (currentTarget === TARGET_DEPARTMENTS) {
const code = entity.departmentCode ? `код ${entity.departmentCode}` : '';
return code ? `${departmentName(entity)} · ${code}` : departmentName(entity);
}
const details = [entity.building, entity.floor ? `${entity.floor} этаж` : null].filter(Boolean).join(', ');
return details ? `${entity.name} · ${details}` : entity.name;
}
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.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 groupLessonsByEntitySlot(lessons) {
const grouped = new Map();
uniqueLessons(lessons).forEach(lesson => {
entityIdsForLesson(lesson).forEach(entityId => {
const key = entitySlotKey(entityId, 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 entityIdsForLesson(lesson) {
if (currentTarget === TARGET_TEACHERS) {
return lesson.teacherId ? [lesson.teacherId] : [];
}
if (currentTarget === TARGET_DEPARTMENTS) {
return departmentIdsForLesson(lesson);
}
return lesson.classroomId ? [lesson.classroomId] : [];
}
function departmentIdsForLesson(lesson) {
const departmentId = teacherDepartmentId(lesson.teacherId);
return departmentId == null ? [] : [departmentId];
}
function renderGrid() {
const rows = visibleEntities();
const target = targetConfig();
renderHeader();
tbody.innerHTML = '';
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">${escapeHtml(target.emptyLabel)}</td></tr>`;
renderSummary(0, 0, 0);
return;
}
rows.forEach(entity => {
const tr = document.createElement('tr');
const entityCell = document.createElement('td');
entityCell.className = `axis-cell${currentTarget === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`;
entityCell.innerHTML = entityAxisHtml(entity);
tr.appendChild(entityCell);
timeSlots.forEach(slot => {
const td = document.createElement('td');
const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.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(rows);
renderSummary(rows.length, occupiedCells, rows.length * timeSlots.length);
}
function renderHeader() {
const target = targetConfig();
headerRow.innerHTML = `
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">${escapeHtml(target.rowLabel)}</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 entityAxisHtml(entity) {
if (currentTarget === TARGET_TEACHERS) {
return `
<strong>${escapeHtml(userName(entity))}</strong>
<span>${escapeHtml(entity.jobTitle || 'Должность не указана')}</span>
${entity.departmentName ? `<small>${escapeHtml(entity.departmentName)}</small>` : ''}
`;
}
if (currentTarget === TARGET_DEPARTMENTS) {
return `
<strong>${escapeHtml(departmentName(entity))}</strong>
<span>${entity.departmentCode ? `Код: ${escapeHtml(entity.departmentCode)}` : 'Код не указан'}</span>
`;
}
return `
<strong>${escapeHtml(entity.name)}</strong>
<span>${entity.capacity ? `${escapeHtml(entity.capacity)} мест` : 'Вместимость не указана'}</span>
${entity.building ? `<small>${escapeHtml(entity.building)}${entity.floor ? `, ${escapeHtml(entity.floor)} этаж` : ''}</small>` : ''}
`;
}
function entityName(entity) {
if (currentTarget === TARGET_TEACHERS) return userName(entity);
if (currentTarget === TARGET_DEPARTMENTS) return departmentName(entity);
return entity?.name || 'Аудитория без названия';
}
function entitySummaryDetails(entity) {
if (currentTarget === TARGET_TEACHERS) {
return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
}
if (currentTarget === TARGET_DEPARTMENTS) {
return entity.departmentCode ? `код ${entity.departmentCode}` : '';
}
return [
entity.capacity ? `${entity.capacity} мест` : null,
entity.building || null,
entity.floor ? `${entity.floor} этаж` : null
].filter(Boolean).join(', ');
}
function renderEntityWorkload(entity, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
const slotRows = buildSlotRows(dates, slotsByDate, lessons);
const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons);
renderRoomSummary(entity, 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(entity, 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 details = entitySummaryDetails(entity);
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(entityName(entity))}</strong>
${details ? `<span>${escapeHtml(details)}</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>${escapeHtml(targetConfig().periodTitle)}</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(entityCount, 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}` : '';
const selected = selectedEntityId ? ', выбран один объект' : '';
summary.textContent = `Дата: ${date}, ${day}. ${targetConfig().summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
}
function countOccupiedCells(entities) {
return entities.reduce((count, entity) => count + timeSlots.filter(slot =>
(lessonsByEntitySlot.get(entitySlotKey(entity.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 entitySlotKey(entityId, timeSlotId) {
return `${entityId}:${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 teacherDepartmentId(teacherId) {
const teacher = teachers.find(item => sameId(item.id, teacherId));
if (!teacher) return null;
if (teacher.departmentId != null) return teacher.departmentId;
if (!teacher.departmentName) return null;
return departments.find(department => departmentName(department) === teacher.departmentName)?.id ?? null;
}
function userName(user) {
return user?.fullName || user?.username || 'Преподаватель без имени';
}
function departmentName(department) {
return department?.departmentName || department?.name || 'Кафедра без названия';
}
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' });
}
}