ролевая моделю доступа + загруженность по кафедрам и преподавателям + просморт расписания
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function initClassrooms() {
|
||||
|
||||
async function loadClassrooms() {
|
||||
try {
|
||||
const classrooms = await api.get('/api/classrooms');
|
||||
const classrooms = await api.get('/api/classrooms?includeArchived=true');
|
||||
renderClassrooms(classrooms);
|
||||
} catch (e) {
|
||||
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
@@ -57,7 +57,7 @@ export async function initClassrooms() {
|
||||
<td>
|
||||
<div class="status-cell">
|
||||
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
|
||||
${c.isAvailable ? 'Доступна' : 'Не доступна'}
|
||||
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
|
||||
</span>
|
||||
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
|
||||
@@ -66,7 +66,9 @@ export async function initClassrooms() {
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button>
|
||||
<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Удалить</button>
|
||||
${c.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -100,11 +102,19 @@ export async function initClassrooms() {
|
||||
const btnEdit = e.target.closest('.btn-edit-classroom');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Удалить аудиторию?')) return;
|
||||
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
|
||||
try {
|
||||
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка удаления'); }
|
||||
} catch (err) { alert('Ошибка архивирования'); }
|
||||
}
|
||||
|
||||
const btnRestore = e.target.closest('.btn-restore-classroom');
|
||||
if (btnRestore) {
|
||||
try {
|
||||
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка восстановления аудитории'); }
|
||||
}
|
||||
|
||||
if (btnToggleStatus) {
|
||||
@@ -124,7 +134,7 @@ export async function initClassrooms() {
|
||||
async function openEditClassroomModal(id) {
|
||||
try {
|
||||
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data
|
||||
const classrooms = await api.get('/api/classrooms');
|
||||
const classrooms = await api.get('/api/classrooms?includeArchived=true');
|
||||
editingClassroomData = classrooms.find(c => c.id == id);
|
||||
|
||||
if (!editingClassroomData) return;
|
||||
|
||||
263
frontend/admin/js/views/department-workspace.js
Normal file
263
frontend/admin/js/views/department-workspace.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
|
||||
export async function initDepartmentWorkspace() {
|
||||
const role = localStorage.getItem('role');
|
||||
const userDepartmentId = localStorage.getItem('departmentId');
|
||||
const departmentField = document.getElementById('department-workspace-department-field');
|
||||
const departmentSelect = document.getElementById('department-workspace-department');
|
||||
const startInput = document.getElementById('department-workspace-start');
|
||||
const endInput = document.getElementById('department-workspace-end');
|
||||
const refreshButton = document.getElementById('department-workspace-refresh');
|
||||
const importForm = document.getElementById('department-subject-import-form');
|
||||
const subjectsTbody = document.getElementById('department-subjects-tbody');
|
||||
const subjectCount = document.getElementById('department-subject-count');
|
||||
const commentsCard = document.getElementById('department-comments-card');
|
||||
const commentsTitle = document.getElementById('department-comments-title');
|
||||
const commentsList = document.getElementById('department-comment-list');
|
||||
const commentsForm = document.getElementById('department-comment-form');
|
||||
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
|
||||
const commentsTextInput = document.getElementById('department-comment-text');
|
||||
const commentsCloseButton = document.getElementById('department-comments-close');
|
||||
|
||||
setDefaultDates(startInput, endInput);
|
||||
refreshButton?.addEventListener('click', loadWorkspace);
|
||||
departmentSelect?.addEventListener('change', loadWorkspace);
|
||||
startInput?.addEventListener('change', loadWorkload);
|
||||
endInput?.addEventListener('change', loadWorkload);
|
||||
importForm?.addEventListener('submit', importSubjects);
|
||||
commentsForm?.addEventListener('submit', saveComment);
|
||||
commentsCloseButton?.addEventListener('click', () => {
|
||||
commentsCard.hidden = true;
|
||||
commentsSubjectInput.value = '';
|
||||
});
|
||||
subjectsTbody?.addEventListener('click', event => {
|
||||
const button = event.target.closest('.department-comments-open');
|
||||
if (button) {
|
||||
openComments(button.dataset.subjectId, button.dataset.subjectName);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await loadDepartments(role, userDepartmentId);
|
||||
if (role === ROLE_DEPARTMENT) {
|
||||
departmentField.hidden = true;
|
||||
departmentSelect.value = userDepartmentId || '';
|
||||
}
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
hideAlert('department-workspace-alert');
|
||||
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
const departmentId = selectedDepartmentId();
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
subjectCount.textContent = 'Загрузка...';
|
||||
try {
|
||||
const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`);
|
||||
subjectCount.textContent = subjectCountLabel(subjects.length);
|
||||
if (!subjects.length) {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Дисциплины не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
subjectsTbody.innerHTML = subjects.map(subject => `
|
||||
<tr>
|
||||
<td>${escapeHtml(subject.code || '-')}</td>
|
||||
<td>${escapeHtml(subject.name || '-')}</td>
|
||||
<td>${escapeHtml(subject.description || '-')}</td>
|
||||
<td>
|
||||
<button type="button" class="btn-edit-classroom department-comments-open"
|
||||
data-subject-id="${escapeHtml(subject.id)}"
|
||||
data-subject-name="${escapeHtml(subject.name || '')}">
|
||||
Открыть
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}</td></tr>`;
|
||||
subjectCount.textContent = '0 дисциплин';
|
||||
}
|
||||
}
|
||||
|
||||
async function importSubjects(event) {
|
||||
event.preventDefault();
|
||||
const alertId = 'department-subject-import-alert';
|
||||
hideAlert(alertId);
|
||||
const departmentId = selectedDepartmentId();
|
||||
const rows = document.getElementById('department-subject-import-text').value
|
||||
.split('\n')
|
||||
.map(row => row.trim())
|
||||
.filter(Boolean)
|
||||
.map(row => {
|
||||
const [code, ...nameParts] = row.split(';');
|
||||
return {
|
||||
code: (code || '').trim(),
|
||||
name: nameParts.join(';').trim(),
|
||||
departmentId: Number(departmentId)
|
||||
};
|
||||
})
|
||||
.filter(item => item.name);
|
||||
|
||||
if (!departmentId) {
|
||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.post('/api/department/subjects/import', rows);
|
||||
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
|
||||
document.getElementById('department-subject-import-text').value = '';
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function openComments(subjectId, subjectName) {
|
||||
commentsCard.hidden = false;
|
||||
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
|
||||
commentsSubjectInput.value = subjectId;
|
||||
commentsTextInput.value = '';
|
||||
hideAlert('department-comment-alert');
|
||||
commentsList.textContent = 'Загрузка...';
|
||||
|
||||
try {
|
||||
const comments = await api.get(`/api/department/subjects/${subjectId}/comments`);
|
||||
commentsList.innerHTML = comments.length
|
||||
? comments.map(comment => `
|
||||
<article class="department-comment-item">
|
||||
<strong>${escapeHtml(comment.authorName || 'Автор не указан')}</strong>
|
||||
<span>${escapeHtml(formatDateTime(comment.createdAt))}</span>
|
||||
<p>${escapeHtml(comment.comment)}</p>
|
||||
</article>
|
||||
`).join('')
|
||||
: '<div class="loading-row">Комментариев пока нет</div>';
|
||||
} catch (error) {
|
||||
commentsList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки комментариев')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment(event) {
|
||||
event.preventDefault();
|
||||
const subjectId = commentsSubjectInput.value;
|
||||
const comment = commentsTextInput.value.trim();
|
||||
if (!subjectId) {
|
||||
showAlert('department-comment-alert', 'Выберите дисциплину', 'error');
|
||||
return;
|
||||
}
|
||||
if (!comment) {
|
||||
showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/api/department/subjects/${subjectId}/comments`, { comment });
|
||||
showAlert('department-comment-alert', 'Комментарий сохранён', 'success');
|
||||
await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', ''));
|
||||
} catch (error) {
|
||||
showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeachers() {
|
||||
const container = document.getElementById('department-teachers-list');
|
||||
container.textContent = 'Загрузка...';
|
||||
try {
|
||||
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
||||
container.innerHTML = teachers.length
|
||||
? teachers.map(teacher => `
|
||||
<article class="metric-card">
|
||||
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
|
||||
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
|
||||
</article>
|
||||
`).join('')
|
||||
: '<div class="loading-row">Преподаватели не найдены</div>';
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkload() {
|
||||
const container = document.getElementById('department-workload-list');
|
||||
const params = new URLSearchParams({
|
||||
startDate: startInput.value,
|
||||
endDate: endInput.value
|
||||
});
|
||||
if (selectedDepartmentId()) {
|
||||
params.set('departmentId', selectedDepartmentId());
|
||||
}
|
||||
container.textContent = 'Загрузка...';
|
||||
try {
|
||||
const workload = await api.get(`/api/workload/teachers?${params}`);
|
||||
container.innerHTML = workload.length
|
||||
? workload.map(item => `
|
||||
<article class="metric-card">
|
||||
<strong>${escapeHtml(item.name || '-')}</strong>
|
||||
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
|
||||
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
|
||||
</article>
|
||||
`).join('')
|
||||
: '<div class="loading-row">Нагрузка не найдена</div>';
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function selectedDepartmentId() {
|
||||
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartments(role, userDepartmentId) {
|
||||
const departments = await api.get('/api/departments');
|
||||
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
|
||||
if (role === ROLE_DEPARTMENT && userDepartmentId) {
|
||||
document.getElementById('department-workspace-department').value = userDepartmentId;
|
||||
} else if (!document.getElementById('department-workspace-department').value && departments.length) {
|
||||
document.getElementById('department-workspace-department').value = departments[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
|
||||
const select = document.getElementById(id);
|
||||
if (!select) return;
|
||||
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
|
||||
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
|
||||
}
|
||||
|
||||
function setDefaultDates(startInput, endInput) {
|
||||
const today = new Date();
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(today.getDate() + 7);
|
||||
startInput.value = toIsoDate(today);
|
||||
endInput.value = toIsoDate(nextWeek);
|
||||
}
|
||||
|
||||
function toIsoDate(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
return String(value).replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function subjectCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
|
||||
if (count % 10 === 1) return `${count} дисциплина`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
|
||||
return `${count} дисциплин`;
|
||||
}
|
||||
355
frontend/admin/js/views/schedule-view.js
Normal file
355
frontend/admin/js/views/schedule-view.js
Normal file
@@ -0,0 +1,355 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
const PARITY_LABELS = {
|
||||
BOTH: 'каждая',
|
||||
ODD: 'нечётная',
|
||||
EVEN: 'чётная'
|
||||
};
|
||||
|
||||
export async function initScheduleView() {
|
||||
const role = localStorage.getItem('role');
|
||||
const userDepartmentId = localStorage.getItem('departmentId');
|
||||
const startInput = document.getElementById('schedule-view-start');
|
||||
const endInput = document.getElementById('schedule-view-end');
|
||||
const loadButton = document.getElementById('schedule-view-load');
|
||||
const departmentField = document.getElementById('schedule-view-department-field');
|
||||
const departmentSelect = document.getElementById('schedule-view-department');
|
||||
const tables = document.getElementById('schedule-view-tables');
|
||||
const countLabel = document.getElementById('schedule-view-count');
|
||||
|
||||
setDefaultDates(startInput, endInput);
|
||||
loadButton?.addEventListener('click', loadData);
|
||||
|
||||
try {
|
||||
await loadDictionaries(role, userDepartmentId);
|
||||
if (role === ROLE_DEPARTMENT) {
|
||||
departmentField.hidden = true;
|
||||
departmentSelect.value = userDepartmentId || '';
|
||||
}
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
setTableState(error.message || 'Ошибка загрузки справочников');
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
hideAlert('schedule-view-alert');
|
||||
setTableState('Загрузка...');
|
||||
countLabel.textContent = 'Загрузка...';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
startDate: startInput.value,
|
||||
endDate: endInput.value
|
||||
});
|
||||
addOptional(params, 'groupId', valueOf('schedule-view-group'));
|
||||
addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
|
||||
addOptional(params, 'classroomId', valueOf('schedule-view-classroom'));
|
||||
addOptional(params, 'departmentId', role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value);
|
||||
addOptional(params, 'subjectId', valueOf('schedule-view-subject'));
|
||||
addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type'));
|
||||
addOptional(params, 'parity', valueOf('schedule-view-parity'));
|
||||
|
||||
try {
|
||||
const lessons = await api.get(`/api/schedule/search?${params}`);
|
||||
renderLessons(lessons);
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки расписания';
|
||||
showAlert('schedule-view-alert', message, 'error');
|
||||
setTableState(message);
|
||||
countLabel.textContent = '0 занятий';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLessons(lessons) {
|
||||
countLabel.textContent = lessonCountLabel(lessons.length);
|
||||
if (!lessons.length) {
|
||||
setTableState('Занятия не найдены');
|
||||
return;
|
||||
}
|
||||
const slots = collectSlots(lessons);
|
||||
const lessonsByDateSlot = groupLessonsByDateSlot(lessons);
|
||||
const weeks = groupDatesByWeek(lessons.map(lesson => lesson.date));
|
||||
tables.innerHTML = weeks
|
||||
.map(week => renderWeekTable(week, slots, lessonsByDateSlot))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderWeekTable(week, slots, lessonsByDateSlot) {
|
||||
const rows = week.dates.map(date => `
|
||||
<tr>
|
||||
<td class="axis-cell schedule-view-date-cell">
|
||||
<strong>${escapeHtml(dayTitle(date, lessonsByDateSlot))}</strong>
|
||||
<span>${escapeHtml(formatDate(date))}</span>
|
||||
</td>
|
||||
${slots.map(slot => renderSlotCell(date, slot, lessonsByDateSlot)).join('')}
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<section class="schedule-view-week-panel">
|
||||
<div class="room-week-heading">
|
||||
<h3>${escapeHtml(week.label)}</h3>
|
||||
<span>${escapeHtml(weekCaption(week.dates))}</span>
|
||||
</div>
|
||||
<div class="workload-grid-container schedule-view-grid-container">
|
||||
<table class="workload-table schedule-view-matrix-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="top-left-cell">
|
||||
<span class="top-label">Время</span>
|
||||
<span class="bottom-label">Дата</span>
|
||||
</th>
|
||||
${slots.map(renderSlotHeader).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSlotHeader(slot) {
|
||||
return `
|
||||
<th>
|
||||
<strong>${escapeHtml(slot.orderLabel)}</strong>
|
||||
<span>${escapeHtml(slot.timeLabel)}</span>
|
||||
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
|
||||
</th>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSlotCell(date, slot, lessonsByDateSlot) {
|
||||
const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || [];
|
||||
return `
|
||||
<td class="${lessons.length ? 'workload-busy-cell' : 'workload-free-cell'}">
|
||||
${lessons.length ? lessons.map(renderLessonCard).join('') : '<span class="free-slot">Свободно</span>'}
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLessonCard(lesson) {
|
||||
const subgroupText = (lesson.subgroupNames || []).join(', ');
|
||||
const meta = [
|
||||
lesson.lessonTypeName,
|
||||
lesson.lessonFormat,
|
||||
PARITY_LABELS[lesson.parity] || lesson.parity
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return `
|
||||
<div class="lesson-card schedule-view-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.classroomName || 'Аудитория не указана')}</div>
|
||||
${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''}
|
||||
${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function setTableState(message) {
|
||||
if (!tables) return;
|
||||
tables.innerHTML = `<div class="loading-row schedule-view-state">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDictionaries(role, userDepartmentId) {
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/lesson-types')
|
||||
]);
|
||||
|
||||
fillSelect('schedule-view-group', groups, item => item.id, item => item.name, 'Все группы');
|
||||
fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели');
|
||||
fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории');
|
||||
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
|
||||
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
|
||||
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
|
||||
|
||||
if (role === ROLE_DEPARTMENT && userDepartmentId) {
|
||||
document.getElementById('schedule-view-department').value = userDepartmentId;
|
||||
}
|
||||
}
|
||||
|
||||
function setDefaultDates(startInput, endInput) {
|
||||
const today = new Date();
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(today.getDate() + 7);
|
||||
startInput.value = toIsoDate(today);
|
||||
endInput.value = toIsoDate(nextWeek);
|
||||
}
|
||||
|
||||
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
|
||||
const select = document.getElementById(id);
|
||||
if (!select) return;
|
||||
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
|
||||
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
|
||||
}
|
||||
|
||||
function addOptional(params, key, value) {
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
|
||||
function valueOf(id) {
|
||||
return document.getElementById(id)?.value || '';
|
||||
}
|
||||
|
||||
function roomLabel(room) {
|
||||
return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function formatSlot(lesson) {
|
||||
const order = lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара';
|
||||
const time = lesson.startTime && lesson.endTime
|
||||
? ` (${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)})`
|
||||
: '';
|
||||
return `${order}${time}`;
|
||||
}
|
||||
|
||||
function collectSlots(lessons) {
|
||||
const slots = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const key = lessonSlotKey(lesson);
|
||||
const existing = slots.get(key);
|
||||
const next = {
|
||||
key,
|
||||
id: lesson.timeSlotId,
|
||||
orderNumber: lesson.timeSlotOrder,
|
||||
startTime: lesson.startTime,
|
||||
endTime: lesson.endTime,
|
||||
orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара',
|
||||
timeLabel: lesson.startTime || lesson.endTime
|
||||
? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}`
|
||||
: 'Время не указано'
|
||||
};
|
||||
if (!existing || existing.timeLabel === 'Время не указано') {
|
||||
slots.set(key, next);
|
||||
}
|
||||
});
|
||||
return Array.from(slots.values()).sort(compareSlots);
|
||||
}
|
||||
|
||||
function groupLessonsByDateSlot(lessons) {
|
||||
const grouped = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
if (!lesson.date) return;
|
||||
const key = dateSlotKey(lesson.date, lessonSlotKey(lesson));
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(lesson);
|
||||
});
|
||||
grouped.forEach(items => {
|
||||
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)
|
||||
|| naturalCompare(a.teacherName, b.teacherName)
|
||||
|| naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(',')));
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function groupDatesByWeek(dates) {
|
||||
const groups = new Map();
|
||||
Array.from(new Set(dates.filter(Boolean)))
|
||||
.sort((a, b) => naturalCompare(a, b))
|
||||
.forEach(date => {
|
||||
const key = weekStart(date);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(date);
|
||||
});
|
||||
return Array.from(groups.entries()).map(([startDate, weekDates]) => ({
|
||||
startDate,
|
||||
dates: weekDates,
|
||||
label: `Неделя ${formatDate(startDate)} - ${formatDate(addDaysToIso(startDate, 6))}`
|
||||
}));
|
||||
}
|
||||
|
||||
function dayTitle(date, lessonsByDateSlot) {
|
||||
for (const [key, lessons] of lessonsByDateSlot.entries()) {
|
||||
if (key.startsWith(`${date}:`) && lessons[0]?.dayName) {
|
||||
return lessons[0].dayName;
|
||||
}
|
||||
}
|
||||
return dayName(date);
|
||||
}
|
||||
|
||||
function weekCaption(dates) {
|
||||
const first = dates[0];
|
||||
const last = dates[dates.length - 1];
|
||||
return `${formatDate(first)} - ${formatDate(last)}`;
|
||||
}
|
||||
|
||||
function lessonSlotKey(lesson) {
|
||||
const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`;
|
||||
return value ? String(value) : 'unknown';
|
||||
}
|
||||
|
||||
function dateSlotKey(date, slotKey) {
|
||||
return `${date}:${slotKey}`;
|
||||
}
|
||||
|
||||
function weekStart(value) {
|
||||
const date = parseIsoDate(value);
|
||||
const day = date.getDay() || 7;
|
||||
date.setDate(date.getDate() + 1 - day);
|
||||
return toIsoDate(date);
|
||||
}
|
||||
|
||||
function addDaysToIso(value, days) {
|
||||
const date = parseIsoDate(value);
|
||||
date.setDate(date.getDate() + days);
|
||||
return toIsoDate(date);
|
||||
}
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function dayName(value) {
|
||||
return parseIsoDate(value).toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function toIsoDate(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function lessonCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} занятий`;
|
||||
if (count % 10 === 1) return `${count} занятие`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} занятия`;
|
||||
return `${count} занятий`;
|
||||
}
|
||||
|
||||
function compareSlots(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 naturalCompare(a, b) {
|
||||
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' };
|
||||
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' };
|
||||
const ROLE_LABELS = {
|
||||
ADMIN: 'Администратор',
|
||||
EDUCATION_OFFICE: 'Учебный отдел',
|
||||
DEPARTMENT: 'Кафедра',
|
||||
SCHEDULE_VIEWER: 'Просмотр расписаний',
|
||||
TEACHER: 'Преподаватель',
|
||||
STUDENT: 'Студент'
|
||||
};
|
||||
const ROLE_BADGE = {
|
||||
ADMIN: 'badge-admin',
|
||||
EDUCATION_OFFICE: 'badge-admin',
|
||||
DEPARTMENT: 'badge-teacher',
|
||||
SCHEDULE_VIEWER: 'badge-student',
|
||||
TEACHER: 'badge-teacher',
|
||||
STUDENT: 'badge-student'
|
||||
};
|
||||
|
||||
export async function initUsers() {
|
||||
const usersTbody = document.getElementById('users-tbody');
|
||||
@@ -10,7 +24,7 @@ export async function initUsers() {
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await api.get('/api/users');
|
||||
const users = await api.get('/api/users?includeArchived=true');
|
||||
renderUsers(users);
|
||||
} catch (e) {
|
||||
usersTbody.innerHTML =
|
||||
@@ -34,7 +48,9 @@ export async function initUsers() {
|
||||
<td>${escapeHtml(u.departmentName || '-')}</td>
|
||||
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
|
||||
<td>
|
||||
<button class="btn-delete" data-id="${u.id}">Удалить</button>
|
||||
${u.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${u.id}">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -77,12 +93,24 @@ export async function initUsers() {
|
||||
const deleteBtn = e.target.closest('.btn-delete');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
if (!confirm('Удалить пользователя?')) return;
|
||||
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
|
||||
try {
|
||||
await api.delete('/api/users/' + deleteBtn.dataset.id);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка удаления');
|
||||
alert(err.message || 'Ошибка архивирования');
|
||||
}
|
||||
});
|
||||
|
||||
usersTbody.addEventListener('click', async (e) => {
|
||||
const restoreBtn = e.target.closest('.btn-restore-user');
|
||||
if (!restoreBtn) return;
|
||||
|
||||
try {
|
||||
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка восстановления');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user