ролевая моделю доступа + загруженность по кафедрам и преподавателям + просморт расписания

This commit is contained in:
Zuev
2026-05-19 23:10:56 +03:00
parent f010ffc467
commit 91729cff7d
93 changed files with 4264 additions and 699 deletions

View File

@@ -3,9 +3,44 @@ 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: 'Вт',
@@ -43,6 +78,7 @@ const CAPACITY_BUCKETS = [
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');
@@ -55,14 +91,18 @@ export async function initAuditoriumWorkload() {
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
let classrooms = [];
let teachers = [];
let departments = [];
let timeSlots = [];
let equipments = [];
let groups = [];
let semesters = [];
let lessonsByRoomSlot = new Map();
let lessonsByEntitySlot = new Map();
let failedGroupLoads = 0;
let currentTarget = TARGET_CLASSROOMS;
let currentView = DISPLAY_ALL;
let selectedRoomId = null;
let selectedEntityId = null;
setDefaultDate();
initFilters();
@@ -84,6 +124,16 @@ export async function initAuditoriumWorkload() {
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();
@@ -106,25 +156,30 @@ export async function initAuditoriumWorkload() {
}
async function loadInitialData() {
setTableLoading('Загрузка аудиторий и расписания...');
setTableLoading('Загрузка справочников и расписания...');
try {
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears] = await Promise.all([
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/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 || 'Ошибка загрузки данных загруженности аудиторий';
const message = error.message || 'Ошибка загрузки данных загруженности';
setTableError(message);
setRoomError(message);
}
@@ -137,10 +192,10 @@ export async function initAuditoriumWorkload() {
if (currentView === DISPLAY_ALL) {
await loadScheduleForSelectedDate();
} else {
await loadRoomWorkload();
await loadSelectedEntityWorkload();
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности аудиторий';
const message = error.message || 'Ошибка загрузки загруженности';
if (currentView === DISPLAY_ALL) {
setTableError(message);
} else {
@@ -162,9 +217,9 @@ export async function initAuditoriumWorkload() {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
if (!groups.length) {
lessonsByRoomSlot = new Map();
lessonsByEntitySlot = new Map();
renderGrid();
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
showAlert('workload-alert', 'Группы не созданы, загруженность пуста', 'error');
return;
}
@@ -176,7 +231,7 @@ export async function initAuditoriumWorkload() {
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
lessonsByRoomSlot = groupLessonsByRoomSlot(lessons);
lessonsByEntitySlot = groupLessonsByEntitySlot(lessons);
renderGrid();
if (failedGroupLoads > 0) {
@@ -184,30 +239,31 @@ export async function initAuditoriumWorkload() {
}
}
async function loadRoomWorkload() {
async function loadSelectedEntityWorkload() {
const date = dateInput?.value;
const room = selectedRoom();
const entity = selectedEntity();
const target = targetConfig();
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
setRoomError('Выберите дату для расчёта двухнедельного периода');
return;
}
if (!room) {
showAlert('workload-alert', 'Выберите аудиторию', 'error');
setRoomError('Выберите аудиторию в поле «Отображение»');
if (!entity) {
showAlert('workload-alert', target.selectError, 'error');
setRoomError(`${target.selectError} в поле «Отображение»`);
return;
}
hideAlert('workload-alert');
setRoomLoading('Загрузка расписания аудитории на две недели...');
setRoomLoading('Загрузка расписания на две недели...');
const range = twoWeekRange(date);
const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates),
loadRoomLessons(range, room.id)
loadEntityLessons(range, entity.id)
]);
renderRoomWorkload(room, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
renderEntityWorkload(entity, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
failedSlotLoads: slotLoad.failedLoads,
failedGroupLoads: lessonLoad.failedLoads,
parityLessons: lessonLoad.parityLessons
@@ -236,7 +292,7 @@ export async function initAuditoriumWorkload() {
};
}
async function loadRoomLessons(range, roomId) {
async function loadEntityLessons(range, entityId) {
if (!groups.length) {
return {
lessons: [],
@@ -252,9 +308,8 @@ export async function initAuditoriumWorkload() {
const parityLessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
const lessons = uniqueLessons(
parityLessons.filter(lesson => sameId(lesson.classroomId, roomId))
);
const lessons = uniqueLessons(parityLessons)
.filter(lesson => entityIdsForLesson(lesson).some(id => sameId(id, entityId)));
const lessonsByDateSlot = new Map();
lessons.forEach(lesson => {
const slotKey = lessonSlotKey(lesson);
@@ -311,12 +366,11 @@ export async function initAuditoriumWorkload() {
function populateDisplayOptions() {
if (!displaySelect) return;
const selectedValue = displaySelect.value;
const target = targetConfig();
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}`));
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;
@@ -325,19 +379,24 @@ export async function initAuditoriumWorkload() {
function applyDisplaySelection() {
const value = displaySelect?.value || DISPLAY_ALL;
if (value.startsWith(ROOM_PREFIX)) {
currentView = 'room';
selectedRoomId = value.slice(ROOM_PREFIX.length);
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;
selectedRoomId = null;
}
function syncDisplayMode() {
const roomMode = currentView !== DISPLAY_ALL;
overviewFilters.forEach(filter => {
filter.hidden = roomMode;
filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS;
});
if (overviewBlock) overviewBlock.hidden = roomMode;
if (roomBlock) roomBlock.hidden = !roomMode;
@@ -346,9 +405,39 @@ export async function initAuditoriumWorkload() {
}
}
function selectedRoom() {
if (!selectedRoomId) return null;
return classrooms.find(room => sameId(room.id, selectedRoomId)) || null;
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() {
@@ -394,7 +483,7 @@ export async function initAuditoriumWorkload() {
function uniqueLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
if (!lesson.classroomId || !lesson.timeSlotId) return;
if (!lesson.timeSlotId) return;
const key = [
lesson.date,
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
@@ -422,12 +511,14 @@ export async function initAuditoriumWorkload() {
return Array.from(unique.values());
}
function groupLessonsByRoomSlot(lessons) {
function groupLessonsByEntitySlot(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);
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));
@@ -435,31 +526,43 @@ export async function initAuditoriumWorkload() {
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 visibleClassrooms = filteredClassrooms();
const rows = visibleEntities();
const target = targetConfig();
renderHeader();
tbody.innerHTML = '';
if (!visibleClassrooms.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">${escapeHtml(target.emptyLabel)}</td></tr>`;
renderSummary(0, 0, 0);
return;
}
visibleClassrooms.forEach(room => {
rows.forEach(entity => {
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);
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 = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || [];
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('')
@@ -470,15 +573,16 @@ export async function initAuditoriumWorkload() {
tbody.appendChild(tr);
});
const occupiedCells = countOccupiedCells(visibleClassrooms);
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length);
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">Аудитория</span>
<span class="bottom-label">${escapeHtml(target.rowLabel)}</span>
</th>
`;
@@ -493,10 +597,51 @@ export async function initAuditoriumWorkload() {
});
}
function renderRoomWorkload(room, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
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(room, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
if (!slotRows.length) {
roomTables.innerHTML = '<div class="loading-row room-workload-state">За выбранный период нет временных слотов</div>';
@@ -506,16 +651,12 @@ export async function initAuditoriumWorkload() {
roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot);
}
function renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
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 roomMeta = [
room.capacity ? `${room.capacity} мест` : null,
room.building || null,
room.floor ? `${room.floor} этаж` : null
].filter(Boolean).join(', ');
const details = entitySummaryDetails(entity);
const warnings = [];
if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`);
if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`);
@@ -523,8 +664,8 @@ export async function initAuditoriumWorkload() {
roomSummary.innerHTML = `
<div class="room-summary-main">
<strong>${escapeHtml(room.name)}</strong>
${roomMeta ? `<span>${escapeHtml(roomMeta)}</span>` : ''}
<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>
@@ -555,7 +696,7 @@ export async function initAuditoriumWorkload() {
return `
<section class="room-week-panel">
<div class="room-week-heading">
<h3>Нагрузка аудитории по неделям</h3>
<h3>${escapeHtml(targetConfig().periodTitle)}</h3>
<span>${escapeHtml(roomWeekCaption(datesByDay))}</span>
</div>
<div class="workload-grid-container room-grid-container">
@@ -852,18 +993,19 @@ export async function initAuditoriumWorkload() {
showAlert('workload-alert', message, 'error');
}
function renderSummary(roomCount, occupiedCells, totalCells) {
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}` : '';
summary.textContent = `Дата: ${date}, ${day}. Аудиторий: ${roomCount}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
const selected = selectedEntityId ? ', выбран один объект' : '';
summary.textContent = `Дата: ${date}, ${day}. ${targetConfig().summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${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
function countOccupiedCells(entities) {
return entities.reduce((count, entity) => count + timeSlots.filter(slot =>
(lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0
).length, 0);
}
@@ -883,8 +1025,8 @@ export async function initAuditoriumWorkload() {
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
}
function roomSlotKey(roomId, timeSlotId) {
return `${roomId}:${timeSlotId}`;
function entitySlotKey(entityId, timeSlotId) {
return `${entityId}:${timeSlotId}`;
}
function dateSlotKey(date, slotKey) {
@@ -905,6 +1047,22 @@ export async function initAuditoriumWorkload() {
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));
}