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

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

@@ -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' });
}