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

418 lines
16 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 DAY_SHORT_LABELS = {
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
};
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 headerRow = document.getElementById('workload-header-row');
const tbody = document.getElementById('workload-tbody');
const summary = document.getElementById('workload-summary');
let classrooms = [];
let timeSlots = [];
let equipments = [];
let groups = [];
let lessonsByRoomSlot = new Map();
let failedGroupLoads = 0;
setDefaultDate();
initFilters();
bindEvents();
await loadInitialData();
function setDefaultDate() {
if (!dateInput || dateInput.value) return;
const today = new Date();
dateInput.value = [
today.getFullYear(),
String(today.getMonth() + 1).padStart(2, '0'),
String(today.getDate()).padStart(2, '0')
].join('-');
}
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', loadScheduleForSelectedDate);
dateInput?.addEventListener('change', loadScheduleForSelectedDate);
[
['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);
renderGrid();
});
});
}
async function loadInitialData() {
setTableLoading('Загрузка аудиторий и расписания...');
try {
[classrooms, equipments, groups] = await Promise.all([
api.get('/api/classrooms'),
api.get('/api/equipments'),
api.get('/api/groups')
]);
classrooms = [...classrooms].sort((a, b) => naturalCompare(a.name, b.name));
populateFilters();
await loadScheduleForSelectedDate();
} catch (error) {
setTableError(error.message || 'Ошибка загрузки данных загруженности аудиторий');
}
}
async function loadScheduleForSelectedDate() {
const date = dateInput?.value;
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
return;
}
hideAlert('workload-alert');
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 params = group => new URLSearchParams({
groupId: String(group.id),
startDate: date,
endDate: date
}).toString();
const responses = await Promise.allSettled(
groups.map(group => api.get(`/api/schedule?${params(group)}`))
);
failedGroupLoads = responses.filter(response => response.status === 'rejected').length;
const lessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
lessonsByRoomSlot = groupLessons(lessons);
renderGrid();
if (failedGroupLoads > 0) {
showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error');
}
}
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 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 groupLessons(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);
return;
}
unique.set(key, {
...lesson,
groupNames: [...(lesson.groupNames || [])],
groupIds: [...(lesson.groupIds || [])]
});
});
const grouped = new Map();
unique.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 renderLessonCard(lesson) {
return `
<div class="lesson-card">
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</div>
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</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 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 roomSlotKey(roomId, timeSlotId) {
return `${roomId}:${timeSlotId}`;
}
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;
const date = new Date(`${dateInput.value}T00:00:00`);
const day = date.getDay();
return day === 0 ? 7 : day;
}
function scopeShort(slot) {
if (slot.scopeApplyMode === 'WEEKDAY') {
return dayShort(slot.scopeDayOfWeek);
}
return 'Ручн.';
}
function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
function trimTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function formatDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
}