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

350 lines
16 KiB
JavaScript

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: 'весенний'
};
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 ruleActiveFromInput = document.getElementById('schedule-rule-active-from');
const ruleHoursInput = document.getElementById('schedule-rule-hours');
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 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 : [{}]);
});
ruleForm.addEventListener('submit', saveRule);
rulesTbody.addEventListener('click', handleRuleTableClick);
}
async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes] = 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')
]);
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="8" class="loading-row">Загрузка...</td></tr>';
try {
rules = await api.get('/api/admin/schedule-rules');
renderRules();
} catch (error) {
rulesTbody.innerHTML = `<tr><td colspan="8" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderRules() {
if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="8" 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>${escapeHtml(rule.activeFromDate || '-')}</td>
<td>${escapeHtml(String(rule.totalAcademicHours ?? '-'))}</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.teacherName,
slot.classroomName,
slot.lessonTypeName,
slot.lessonFormat
].filter(Boolean);
return `<div class="schedule-slot-summary">${escapeHtml(parts.join(' · '))}</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('Выберите семестр');
if (!ruleActiveFromInput.value) throw new Error('Укажите дату начала правила');
if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) throw new Error('Укажите количество академических часов');
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),
activeFromDate: ruleActiveFromInput.value,
totalAcademicHours: Number(ruleHoursInput.value),
groupIds,
slots
};
}
function fillRuleForm(rule) {
ruleFormTitle.textContent = 'Редактирование правила расписания';
ruleIdInput.value = rule.id;
ruleSubjectSelect.value = rule.subjectId || '';
ruleSemesterSelect.value = rule.semesterId || '';
ruleActiveFromInput.value = rule.activeFromDate || '';
ruleHoursInput.value = rule.totalAcademicHours || '';
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 = '';
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-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 slot = {
id: null,
dayOfWeek: Number(row.querySelector('.slot-day').value),
parity: row.querySelector('.slot-parity').value,
timeSlotId: Number(row.querySelector('.slot-time').value),
subgroupId: 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}`);
}
return slot;
});
}
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 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 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 }));
});
}
}