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

905 lines
40 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 ACTIVITY_LABELS = {
THEORY: 'Теория',
EXAM: 'Экзамены',
VACATION: 'Каникулы',
PRACTICE: 'Практика'
};
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');
const timeSlotForm = document.getElementById('time-slot-form');
const timeSlotFormTitle = document.getElementById('time-slot-form-title');
const timeSlotIdInput = document.getElementById('time-slot-id');
const timeSlotOrderInput = document.getElementById('time-slot-order');
const timeSlotStartInput = document.getElementById('time-slot-start');
const timeSlotEndInput = document.getElementById('time-slot-end');
const timeSlotDurationInput = document.getElementById('time-slot-duration');
const timeSlotResetButton = document.getElementById('time-slot-reset');
const timeSlotsTbody = document.getElementById('time-slots-tbody');
const refreshSlotsButton = document.getElementById('time-slots-refresh');
const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title');
const academicYearIdInput = document.getElementById('academic-year-id');
const academicYearTitleInput = document.getElementById('academic-year-title');
const academicYearStartInput = document.getElementById('academic-year-start');
const academicYearEndInput = document.getElementById('academic-year-end');
const academicYearResetButton = document.getElementById('academic-year-reset');
const academicYearsTbody = document.getElementById('academic-years-tbody');
const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
const semesterForm = document.getElementById('semester-form');
const semesterFormTitle = document.getElementById('semester-form-title');
const semesterIdInput = document.getElementById('semester-id');
const semesterYearSelect = document.getElementById('semester-year');
const semesterTypeSelect = document.getElementById('semester-type');
const semesterStartInput = document.getElementById('semester-start');
const semesterEndInput = document.getElementById('semester-end');
const semesterResetButton = document.getElementById('semester-reset');
const holidayForm = document.getElementById('holiday-form');
const holidayIdInput = document.getElementById('holiday-id');
const holidayYearSelect = document.getElementById('holiday-year');
const holidayDateInput = document.getElementById('holiday-date');
const holidayDescriptionInput = document.getElementById('holiday-description');
const holidayResetButton = document.getElementById('holiday-reset');
const holidaysTbody = document.getElementById('holidays-tbody');
const matrixSemesterSelect = document.getElementById('matrix-semester');
const matrixCourseInput = document.getElementById('matrix-course');
const matrixSpecialtySelect = document.getElementById('matrix-specialty');
const matrixLoadButton = document.getElementById('matrix-load');
const matrixSaveButton = document.getElementById('matrix-save');
const matrixGrid = document.getElementById('matrix-grid');
let rules = [];
let subjects = [];
let groups = [];
let teachers = [];
let classrooms = [];
let lessonTypes = [];
let timeSlots = [];
let academicYears = [];
let semesters = [];
let specialties = [];
let holidays = [];
bindEvents();
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
renderRuleGroups([]);
renderRuleSlots([{}]);
await Promise.all([loadRules(), loadHolidays()]);
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
}
function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules);
refreshSlotsButton?.addEventListener('click', loadTimeSlots);
academicYearsRefreshButton?.addEventListener('click', loadYears);
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
timeSlotResetButton?.addEventListener('click', resetTimeSlotForm);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
semesterResetButton?.addEventListener('click', resetSemesterForm);
holidayResetButton?.addEventListener('click', resetHolidayForm);
holidayYearSelect?.addEventListener('change', loadHolidays);
matrixLoadButton?.addEventListener('click', loadMatrix);
matrixSaveButton?.addEventListener('click', saveMatrix);
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);
timeSlotForm.addEventListener('submit', saveTimeSlot);
academicYearForm.addEventListener('submit', saveAcademicYear);
semesterForm.addEventListener('submit', saveSemester);
holidayForm.addEventListener('submit', saveHoliday);
rulesTbody.addEventListener('click', handleRuleTableClick);
timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick);
academicYearsTbody.addEventListener('click', handleAcademicYearTableClick);
holidaysTbody.addEventListener('click', handleHolidayTableClick);
}
async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, specialties] = 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/specialties')
]);
populateSubjectSelects();
populateSpecialtySelects();
}
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 || !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() {
timeSlotsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
try {
timeSlots = await api.get('/api/admin/time-slots');
renderTimeSlots();
} catch (error) {
timeSlotsTbody.innerHTML = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderTimeSlots() {
if (!timeSlots || !timeSlots.length) {
timeSlotsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Сетка пар не настроена</td></tr>';
return;
}
timeSlotsTbody.innerHTML = timeSlots.map(slot => `
<tr>
<td>${slot.orderNumber}</td>
<td>${escapeHtml(trimTime(slot.startTime))}</td>
<td>${escapeHtml(trimTime(slot.endTime))}</td>
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
<td>
<button class="btn-edit-classroom btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
<button class="btn-delete btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function saveTimeSlot(event) {
event.preventDefault();
hideAlert('time-slot-alert');
const payload = {
id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null,
orderNumber: Number(timeSlotOrderInput.value),
startTime: timeSlotStartInput.value,
endTime: timeSlotEndInput.value,
durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null
};
try {
if (!payload.orderNumber || !payload.startTime || !payload.endTime) {
throw new Error('Заполните номер пары, начало и окончание');
}
const id = timeSlotIdInput.value;
await (id
? api.put('/api/admin/time-slots/' + id, payload)
: api.post('/api/admin/time-slots', payload));
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
resetTimeSlotForm();
await loadTimeSlots();
renderRuleSlots(readRuleSlots(false));
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
}
}
async function handleTimeSlotTableClick(event) {
const editButton = event.target.closest('.btn-edit-time-slot');
const deleteButton = event.target.closest('.btn-delete-time-slot');
if (editButton) {
const slot = timeSlots.find(item => item.id == editButton.dataset.id);
if (!slot) return;
timeSlotFormTitle.textContent = 'Редактирование временного слота';
timeSlotIdInput.value = slot.id;
timeSlotOrderInput.value = slot.orderNumber || '';
timeSlotStartInput.value = trimTime(slot.startTime);
timeSlotEndInput.value = trimTime(slot.endTime);
timeSlotDurationInput.value = slot.durationMinutes || '';
hideAlert('time-slot-alert');
timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить временной слот?')) return;
try {
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
await loadTimeSlots();
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
}
}
}
function resetTimeSlotForm() {
timeSlotForm.reset();
timeSlotFormTitle.textContent = 'Новый временной слот';
timeSlotIdInput.value = '';
hideAlert('time-slot-alert');
}
async function loadYears() {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
renderAcademicYears();
populateYearSelects();
populateSemesterSelects();
} catch (error) {
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAcademicYears() {
if (!academicYears || !academicYears.length) {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Учебные годы не созданы</td></tr>';
return;
}
academicYearsTbody.innerHTML = academicYears.map(year => `
<tr>
<td>${escapeHtml(year.title)}</td>
<td>${escapeHtml(year.startDate)}${escapeHtml(year.endDate)}</td>
<td>${renderSemesterList(year.semesters || [])}</td>
<td>
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSemesterList(items) {
if (!items.length) return '<span class="muted">Нет семестров</span>';
return items.map(semester => `
<div class="schedule-semester-line">
${escapeHtml(semesterLabel(semester))}
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button>
</div>
`).join('');
}
async function saveAcademicYear(event) {
event.preventDefault();
hideAlert('academic-year-alert');
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
};
try {
if (!payload.title || !payload.startDate || !payload.endDate) {
throw new Error('Заполните название и даты учебного года');
}
const id = academicYearIdInput.value;
await (id
? api.put('/api/admin/calendar/years/' + id, payload)
: api.post('/api/admin/calendar/years', payload));
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
resetAcademicYearForm();
await loadYears();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error');
}
}
async function handleAcademicYearTableClick(event) {
const editYearButton = event.target.closest('.btn-edit-year');
const deleteYearButton = event.target.closest('.btn-delete-year');
const editSemesterButton = event.target.closest('.btn-edit-semester');
if (editYearButton) {
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
if (!year) return;
academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
academicYearStartInput.value = year.startDate || '';
academicYearEndInput.value = year.endDate || '';
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return;
semesterFormTitle.textContent = 'Редактирование семестра';
semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
semesterStartInput.value = semester.startDate || '';
semesterEndInput.value = semester.endDate || '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами?')) return;
try {
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
await loadYears();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error');
}
}
}
function resetAcademicYearForm() {
academicYearForm.reset();
academicYearFormTitle.textContent = 'Учебный год';
academicYearIdInput.value = '';
hideAlert('academic-year-alert');
}
async function saveSemester(event) {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
};
try {
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра');
}
const id = semesterIdInput.value;
await (id
? api.put('/api/admin/calendar/semesters/' + id, payload)
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
showAlert('semester-alert', 'Семестр сохранён', 'success');
resetSemesterForm();
await loadYears();
} catch (error) {
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
}
}
function resetSemesterForm() {
semesterForm.reset();
semesterFormTitle.textContent = 'Семестр';
semesterIdInput.value = '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
}
async function saveHoliday(event) {
event.preventDefault();
hideAlert('holiday-alert');
const payload = {
id: holidayIdInput.value ? Number(holidayIdInput.value) : null,
academicYearId: Number(holidayYearSelect.value),
date: holidayDateInput.value,
description: holidayDescriptionInput.value.trim()
};
try {
if (!payload.academicYearId || !payload.date) {
throw new Error('Выберите учебный год и дату');
}
const id = holidayIdInput.value;
await (id
? api.put('/api/admin/calendar/holidays/' + id, payload)
: api.post('/api/admin/calendar/holidays', payload));
showAlert('holiday-alert', 'Дата сохранена', 'success');
resetHolidayForm(false);
await loadHolidays();
} catch (error) {
showAlert('holiday-alert', error.message || 'Ошибка сохранения даты', 'error');
}
}
async function loadHolidays() {
const yearId = holidayYearSelect.value;
if (!yearId) {
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите учебный год</td></tr>';
return;
}
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try {
holidays = await api.get('/api/admin/calendar/holidays?academicYearId=' + encodeURIComponent(yearId));
renderHolidays();
} catch (error) {
holidaysTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderHolidays() {
if (!holidays || !holidays.length) {
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Исключения не добавлены</td></tr>';
return;
}
holidaysTbody.innerHTML = holidays.map(holiday => `
<tr>
<td>${escapeHtml(holiday.date)}</td>
<td>${escapeHtml(holiday.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-holiday" data-id="${holiday.id}">Изменить</button>
<button class="btn-delete btn-delete-holiday" data-id="${holiday.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function handleHolidayTableClick(event) {
const editButton = event.target.closest('.btn-edit-holiday');
const deleteButton = event.target.closest('.btn-delete-holiday');
if (editButton) {
const holiday = holidays.find(item => item.id == editButton.dataset.id);
if (!holiday) return;
holidayIdInput.value = holiday.id;
holidayYearSelect.value = holiday.academicYearId || holidayYearSelect.value;
holidayDateInput.value = holiday.date || '';
holidayDescriptionInput.value = holiday.description || '';
syncSelects(holidayYearSelect);
hideAlert('holiday-alert');
holidayForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить дату-исключение?')) return;
try {
await api.delete('/api/admin/calendar/holidays/' + deleteButton.dataset.id);
showAlert('holiday-alert', 'Дата удалена', 'success');
await loadHolidays();
} catch (error) {
showAlert('holiday-alert', error.message || 'Ошибка удаления даты', 'error');
}
}
}
function resetHolidayForm(clearYear = true) {
const yearId = holidayYearSelect.value;
holidayForm.reset();
if (!clearYear) holidayYearSelect.value = yearId;
holidayIdInput.value = '';
syncSelects(holidayYearSelect);
hideAlert('holiday-alert');
}
async function loadMatrix() {
hideAlert('matrix-alert');
const semesterId = matrixSemesterSelect.value;
const courseNumber = Number(matrixCourseInput.value);
const specialtyId = matrixSpecialtySelect.value;
if (!semesterId || !courseNumber || !specialtyId) {
showAlert('matrix-alert', 'Выберите семестр, курс и специальность', 'error');
return;
}
try {
const params = new URLSearchParams({ semesterId, courseNumber: String(courseNumber), specialtyId });
const rows = await api.get('/api/admin/calendar/matrix?' + params.toString());
renderMatrix(rows && rows.length ? rows : buildDefaultMatrixRows(semesterId, courseNumber, specialtyId));
showAlert('matrix-alert', rows && rows.length ? 'Матрица загружена' : 'Матрица не найдена, подготовлена новая сетка', 'success');
} catch (error) {
showAlert('matrix-alert', error.message || 'Ошибка загрузки матрицы', 'error');
}
}
async function saveMatrix() {
hideAlert('matrix-alert');
const rows = Array.from(matrixGrid.querySelectorAll('.matrix-week')).map(item => ({
id: item.dataset.id ? Number(item.dataset.id) : null,
semesterId: Number(item.dataset.semesterId),
courseNumber: Number(item.dataset.courseNumber),
specialtyId: Number(item.dataset.specialtyId),
weekNumber: Number(item.dataset.weekNumber),
activityType: item.querySelector('select').value
}));
if (!rows.length) {
showAlert('matrix-alert', 'Сначала загрузите матрицу', 'error');
return;
}
try {
await api.put('/api/admin/calendar/matrix', rows);
showAlert('matrix-alert', 'Матрица учебного графика сохранена', 'success');
await loadMatrix();
} catch (error) {
showAlert('matrix-alert', error.message || 'Ошибка сохранения матрицы', 'error');
}
}
function renderMatrix(rows) {
matrixGrid.innerHTML = rows.map(row => `
<div class="matrix-week"
data-id="${row.id || ''}"
data-semester-id="${row.semesterId}"
data-course-number="${row.courseNumber}"
data-specialty-id="${row.specialtyId}"
data-week-number="${row.weekNumber}">
<span>${row.weekNumber}</span>
<select>${options(activityOptions(), row.activityType || 'THEORY')}</select>
</div>
`).join('');
}
function buildDefaultMatrixRows(semesterId, courseNumber, specialtyId) {
return Array.from({ length: 22 }, (_, index) => ({
id: null,
semesterId: Number(semesterId),
courseNumber,
specialtyId: Number(specialtyId),
weekNumber: index + 1,
activityType: 'THEORY'
}));
}
function populateSubjectSelects() {
ruleSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(subject => `<option value="${subject.id}">${escapeHtml(subject.name)}</option>`).join('');
}
function populateYearSelects() {
const yearOptions = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
semesterYearSelect.innerHTML = yearOptions;
holidayYearSelect.innerHTML = yearOptions;
if (!holidayYearSelect.value && academicYears.length) {
holidayYearSelect.value = String(academicYears[0].id);
}
syncSelects(semesterYearSelect, holidayYearSelect);
}
function populateSemesterSelects() {
const semesterOptions = '<option value="">Выберите семестр</option>' +
semesters.map(semester => `<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>`).join('');
ruleSemesterSelect.innerHTML = semesterOptions;
matrixSemesterSelect.innerHTML = semesterOptions;
syncSelects(ruleSemesterSelect, matrixSemesterSelect);
}
function populateSpecialtySelects() {
matrixSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
syncSelects(matrixSpecialtySelect);
}
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 activityOptions() {
return Object.entries(ACTIVITY_LABELS).map(([value, label]) => ({ value, label }));
}
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 specialityLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code}${name}` : String(code);
}
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 }));
});
}
}