356 lines
14 KiB
JavaScript
356 lines
14 KiB
JavaScript
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' });
|
||
}
|