Files
magistr/frontend/admin/js/views/schedule.js

499 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник' },
{ value: 2, label: 'Вторник' },
{ value: 3, label: 'Среда' },
{ value: 4, label: 'Четверг' },
{ value: 5, label: 'Пятница' },
{ value: 6, label: 'Суббота' },
{ value: 7, label: 'Воскресенье' }
];
const PARITY_LABELS = {
BOTH: 'каждую неделю',
ODD: 'нечётная',
EVEN: 'чётная'
};
const SEMESTER_LABELS = {
autumn: 'осенний',
spring: 'весенний'
};
const LESSON_TYPE_LIMITS = [
{
key: 'lecture',
label: 'Лекции',
hoursField: 'lectureAcademicHours',
startWeekField: 'lectureStartWeek',
hoursInputId: 'schedule-rule-lecture-hours',
startWeekInputId: 'schedule-rule-lecture-start-week'
},
{
key: 'laboratory',
label: 'Лабораторные',
hoursField: 'laboratoryAcademicHours',
startWeekField: 'laboratoryStartWeek',
hoursInputId: 'schedule-rule-laboratory-hours',
startWeekInputId: 'schedule-rule-laboratory-start-week'
},
{
key: 'practice',
label: 'Практики',
hoursField: 'practiceAcademicHours',
startWeekField: 'practiceStartWeek',
hoursInputId: 'schedule-rule-practice-hours',
startWeekInputId: 'schedule-rule-practice-start-week'
}
];
export async function initSchedule() {
const ruleForm = document.getElementById('schedule-rule-form');
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
const ruleIdInput = document.getElementById('schedule-rule-id');
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
hours: document.getElementById(config.hoursInputId),
startWeek: document.getElementById(config.startWeekInputId)
}]));
const ruleGroupsContainer = document.getElementById('schedule-rule-groups');
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
const ruleResetButton = document.getElementById('schedule-rule-reset');
const slotAddButton = document.getElementById('schedule-slot-add');
const rulesTbody = document.getElementById('schedule-tbody');
const refreshRulesButton = document.getElementById('schedule-refresh');
let rules = [];
let subjects = [];
let groups = [];
let teachers = [];
let classrooms = [];
let lessonTypes = [];
let subgroups = [];
let timeSlots = [];
let semesters = [];
bindEvents();
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
renderRuleGroups([]);
renderRuleSlots([{}]);
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
}
function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules);
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove');
if (!removeButton) return;
const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]);
});
ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false)));
ruleSlotsContainer.addEventListener('change', (event) => {
if (event.target.closest('.slot-lesson-type')) {
renderRuleSlots(readRuleSlots(false));
}
});
ruleForm.addEventListener('submit', saveRule);
rulesTbody.addEventListener('click', handleRuleTableClick);
}
async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
api.get('/api/subjects'),
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/lesson-types'),
api.get('/api/subgroups')
]);
populateSubjectSelects();
}
async function loadYears() {
const academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects();
}
async function loadRules() {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
try {
rules = await api.get('/api/admin/schedule-rules');
renderRules();
} catch (error) {
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderRules() {
if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
return;
}
rulesTbody.innerHTML = rules.map(rule => `
<tr>
<td>${rule.id}</td>
<td>${escapeHtml(rule.subjectName || '-')}</td>
<td>${escapeHtml(formatSemester(rule))}</td>
<td>${escapeHtml((rule.groupNames || []).join(', ') || '-')}</td>
<td>${renderRuleLoadSummary(rule)}</td>
<td>${renderRuleSlotsSummary(rule.slots)}</td>
<td>
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
<button class="btn-delete btn-delete-rule" data-id="${rule.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderRuleSlotsSummary(slots) {
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
return slots.map(slot => {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
const parts = [
slot.dayName,
slot.timeSlotLabel,
parity,
slot.subgroupName,
slot.teacherName,
slot.classroomName,
slot.lessonTypeName,
slot.lessonFormat
].filter(Boolean);
return `<div class="schedule-slot-summary">${escapeHtml(parts.join(' · '))}</div>`;
}).join('');
}
function renderRuleLoadSummary(rule) {
const rows = LESSON_TYPE_LIMITS
.map(config => ({
label: config.label,
hours: Number(rule[config.hoursField] || 0),
startWeek: Number(rule[config.startWeekField] || 1)
}))
.filter(item => item.hours > 0);
if (!rows.length) return '<span class="muted">Часы не указаны</span>';
return rows.map(item => (
`<div class="schedule-slot-summary">${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}</div>`
)).join('');
}
async function handleRuleTableClick(event) {
const editButton = event.target.closest('.btn-edit-rule');
const deleteButton = event.target.closest('.btn-delete-rule');
if (editButton) {
const rule = rules.find(item => item.id == editButton.dataset.id)
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
fillRuleForm(rule);
return;
}
if (deleteButton) {
if (!confirm('Удалить правило расписания?')) return;
try {
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
}
}
}
async function saveRule(event) {
event.preventDefault();
hideAlert('schedule-rule-alert');
try {
const payload = buildRulePayload();
const id = ruleIdInput.value;
const saved = id
? await api.put('/api/admin/schedule-rules/' + id, payload)
: await api.post('/api/admin/schedule-rules', payload);
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
resetRuleForm();
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
}
}
function buildRulePayload() {
const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => Number(input.value));
const slots = readRuleSlots(true);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
const typeLimits = readTypeLimits();
validateTypeLimits(typeLimits, slots);
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
return {
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
subjectId: Number(ruleSubjectSelect.value),
semesterId: Number(ruleSemesterSelect.value),
...typeLimits,
groupIds,
slots
};
}
function fillRuleForm(rule) {
ruleFormTitle.textContent = 'Редактирование правила расписания';
ruleIdInput.value = rule.id;
ruleSubjectSelect.value = rule.subjectId || '';
ruleSemesterSelect.value = rule.semesterId || '';
fillTypeLimits(rule);
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
renderRuleGroups(rule.groupIds || []);
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
hideAlert('schedule-rule-alert');
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function resetRuleForm() {
ruleForm.reset();
ruleFormTitle.textContent = 'Новое правило расписания';
ruleIdInput.value = '';
fillTypeLimits({});
renderRuleGroups([]);
renderRuleSlots([{}]);
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert');
}
function renderRuleGroups(selectedIds) {
if (!groups.length) {
ruleGroupsContainer.innerHTML = '<div class="loading-row">Нет групп</div>';
return;
}
const selected = new Set((selectedIds || []).map(String));
ruleGroupsContainer.innerHTML = groups.map(group => `
<label class="checkbox-item">
<input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(group.name)}</span>
</label>
`).join('');
}
function renderRuleSlots(slots) {
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
<div class="schedule-slot-row" data-index="${index}">
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup" ${isLaboratorySlot(slot) ? '' : 'disabled'}>${subgroupOptions(slot)}</select></div>
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
}
function readRuleSlots(validate) {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => {
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
const slot = {
id: null,
dayOfWeek: Number(row.querySelector('.slot-day').value),
parity: row.querySelector('.slot-parity').value,
timeSlotId: Number(row.querySelector('.slot-time').value),
subgroupId: subgroupValue ? Number(subgroupValue) : null,
teacherId: Number(row.querySelector('.slot-teacher').value),
classroomId: Number(row.querySelector('.slot-classroom').value),
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
lessonFormat: row.querySelector('.slot-format').value
};
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1}`);
}
if (validate) {
validateSlotSubgroup(slot, index);
}
return slot;
});
}
function readTypeLimits() {
return LESSON_TYPE_LIMITS.reduce((payload, config) => {
const inputs = ruleTypeInputs[config.key];
payload[config.hoursField] = Number(inputs.hours.value || 0);
payload[config.startWeekField] = Number(inputs.startWeek.value || 1);
return payload;
}, {});
}
function fillTypeLimits(rule) {
LESSON_TYPE_LIMITS.forEach(config => {
const inputs = ruleTypeInputs[config.key];
inputs.hours.value = rule[config.hoursField] ?? 0;
inputs.startWeek.value = rule[config.startWeekField] ?? 1;
});
}
function validateTypeLimits(typeLimits, slots) {
const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean));
let hasHours = false;
for (const config of LESSON_TYPE_LIMITS) {
const hours = typeLimits[config.hoursField];
const startWeek = typeLimits[config.startWeekField];
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
if (hours > 0) hasHours = true;
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
}
if (hours === 0 && selectedTypeKeys.has(config.key)) {
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
}
}
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
}
function validateSlotSubgroup(slot, index) {
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
if (slot.subgroupId && typeKey !== 'laboratory') {
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
}
if (!slot.subgroupId) return;
const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId));
if (!selectedSubgroup) {
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
}
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
}
}
async function loadTimeSlots() {
const slots = await api.get('/api/admin/time-slots');
timeSlots = slots
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
}
function populateSubjectSelects() {
ruleSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(subject => `<option value="${subject.id}">${escapeHtml(subject.name)}</option>`).join('');
syncSelects(ruleSubjectSelect);
}
function populateSemesterSelects() {
const semesterOptions = '<option value="">Выберите семестр</option>' +
semesters.map(semester => `<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>`).join('');
ruleSemesterSelect.innerHTML = semesterOptions;
syncSelects(ruleSemesterSelect);
}
function options(items, selectedValue) {
const selected = selectedValue == null ? '' : String(selectedValue);
return '<option value="">Выберите</option>' + items.map(item => {
const value = String(item.value);
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(item.label)}</option>`;
}).join('');
}
function subgroupOptions(slot) {
if (!isLaboratorySlot(slot)) {
return '<option value="">Только для лабораторных</option>';
}
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId);
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
const matchingSubgroups = subgroups
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
return '<option value="">Вся группа</option>' + matchingSubgroups.map(subgroup => {
const value = String(subgroup.id);
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
}).join('');
}
function selectedRuleGroupIds() {
return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => Number(input.value));
}
function toTimeSlotOption(slot) {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
}
function toTeacherOption(teacher) {
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
}
function toClassroomOption(classroom) {
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
return { value: classroom.id, label: `${classroom.name}${capacity}` };
}
function toLessonTypeOption(lessonType) {
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
}
function lessonTypeKeyById(lessonTypeId) {
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
return lessonType ? lessonTypeKey(lessonType) : null;
}
function isLaboratorySlot(slot) {
return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory';
}
function subgroupLabel(subgroup) {
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
}
function lessonTypeKey(lessonType) {
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
if (name.includes('лекц')) return 'lecture';
if (name.includes('лаб')) return 'laboratory';
if (name.includes('практ')) return 'practice';
return null;
}
function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
}
function formatSemester(rule) {
const type = normalizeSemesterType(rule.semesterType);
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
}
function normalizeSemesterType(type) {
return String(type || '').toLowerCase();
}
function trimTime(value) {
if (!value) return '';
return String(value).slice(0, 5);
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
}