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 WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
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 ruleGroupsBox = document.getElementById('schedule-rule-groups-box');
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
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');
const subgroupBox = event.target.closest('.slot-subgroup-box');
const subgroupMenu = event.target.closest('.slot-subgroup-menu');
if (removeButton) {
const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]);
return;
}
if (subgroupBox) {
toggleSlotSubgroupMenu(event, subgroupBox);
return;
}
if (subgroupMenu) {
event.stopPropagation();
}
});
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
ruleGroupCheckboxes.addEventListener('change', () => {
updateRuleGroupsText();
renderRuleSlots(readRuleSlots(false));
});
document.addEventListener('click', () => {
closeRuleGroupsMenu();
closeSlotSubgroupMenus();
});
ruleSlotsContainer.addEventListener('change', (event) => {
if (event.target.closest('.slot-lesson-type')) {
renderRuleSlots(readRuleSlots(false));
return;
}
if (event.target.closest('.slot-subgroup-checkboxes')) {
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
}
});
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 = '
| Загрузка... |
';
try {
rules = await api.get('/api/admin/schedule-rules');
renderRules();
} catch (error) {
rulesTbody.innerHTML = `| Ошибка загрузки: ${escapeHtml(error.message)} |
`;
}
}
function renderRules() {
if (!rules.length) {
rulesTbody.innerHTML = '| Правила расписания не созданы |
';
return;
}
rulesTbody.innerHTML = rules.map(rule => `
| ${rule.id} |
${escapeHtml(rule.subjectName || '-')} |
${escapeHtml(formatSemester(rule))} |
${escapeHtml((rule.groupNames || []).join(', ') || '-')} |
${renderRuleLoadSummary(rule)} |
${renderRuleSlotsSummary(rule.slots)} |
|
`).join('');
}
function renderRuleSlotsSummary(slots) {
if (!slots || !slots.length) return 'Нет слотов';
return slots.map(slot => {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
const parts = [
slot.dayName,
slot.timeSlotLabel,
parity,
slotSubgroupNames(slot).join(', '),
slot.teacherName,
slot.classroomName,
slot.lessonTypeName,
slot.lessonFormat
].filter(Boolean);
return `${escapeHtml(parts.join(' · '))}
`;
}).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 'Часы не указаны';
return rows.map(item => (
`${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}
`
)).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 = selectedRuleGroupIds();
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) {
ruleGroupCheckboxes.innerHTML = 'Нет групп
';
updateRuleGroupsText();
return;
}
const selected = new Set((selectedIds || []).map(String));
ruleGroupCheckboxes.innerHTML = groups.map(group => `
`).join('');
updateRuleGroupsText();
}
function renderRuleSlots(slots) {
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
`).join('');
}
function readRuleSlots(validate) {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => {
const subgroupIds = readSlotSubgroupIds(row);
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: subgroupIds[0] || null,
subgroupIds,
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);
const subgroupIds = slotSubgroupIds(slot);
if (subgroupIds.length && typeKey !== 'laboratory') {
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
}
if (!subgroupIds.length) return;
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
const subgroupGroupIds = new Set();
for (const subgroupId of subgroupIds) {
const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId));
if (!selectedSubgroup) {
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
}
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
}
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
}
subgroupGroupIds.add(String(selectedSubgroup.groupId));
}
}
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 = '' +
subjects.map(subject => ``).join('');
syncSelects(ruleSubjectSelect);
}
function populateSemesterSelects() {
const semesterOptions = '' +
semesters.map(semester => ``).join('');
ruleSemesterSelect.innerHTML = semesterOptions;
syncSelects(ruleSemesterSelect);
}
function options(items, selectedValue) {
const selected = selectedValue == null ? '' : String(selectedValue);
return '' + items.map(item => {
const value = String(item.value);
return ``;
}).join('');
}
function subgroupOptions(slot) {
if (!isLaboratorySlot(slot)) {
return '';
}
const selectedGroupIds = selectedRuleGroupIds();
if (selectedGroupIds.length !== 1) {
return '';
}
const selected = slotSubgroupIds(slot)[0] == null
? WHOLE_GROUP_SUBGROUP_VALUE
: String(slotSubgroupIds(slot)[0]);
const selectedGroups = new Set(selectedGroupIds.map(String));
const matchingSubgroups = subgroups
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
return `` + matchingSubgroups.map(subgroup => {
const value = String(subgroup.id);
return ``;
}).join('');
}
function renderSubgroupField(slot, index) {
return selectedRuleGroupIds().length > 1
? renderSubgroupMultiSelect(slot, index)
: ``;
}
function renderSubgroupMultiSelect(slot, index) {
const selected = new Set(slotSubgroupIds(slot).map(String));
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'));
const items = matchingSubgroups.length
? matchingSubgroups.map(subgroup => {
const value = String(subgroup.id);
return `
`;
}).join('')
: 'Нет подгрупп выбранных групп
';
return `
`;
}
function readSlotSubgroupIds(row) {
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked'))
.map(input => Number(input.value))
.filter(Boolean);
return checkboxValues.length
? checkboxValues
: (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []);
}
function slotSubgroupIds(slot) {
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
return slot.subgroupIds.map(Number).filter(Boolean);
}
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
}
function slotSubgroupNames(slot) {
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
return slot.subgroupNames.filter(Boolean);
}
return slot.subgroupName ? [slot.subgroupName] : [];
}
function slotSubgroupText(slot) {
const selectedIds = slotSubgroupIds(slot);
if (!selectedIds.length) return 'Все выбранные группы';
if (selectedIds.length === 1) {
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
}
return `Выбрано подгрупп: ${selectedIds.length}`;
}
function selectedRuleGroupIds() {
return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => Number(input.value));
}
function toggleRuleGroupsMenu(event) {
event.stopPropagation();
const isOpen = ruleGroupsMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (isOpen) {
closeRuleGroupsMenu();
return;
}
ruleGroupsMenu.classList.add('open');
ruleGroupsBox.classList.add('active');
ruleGroupsBox.setAttribute('aria-expanded', 'true');
}
function handleRuleGroupsKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleRuleGroupsMenu(event);
}
if (event.key === 'Escape') {
closeRuleGroupsMenu();
}
}
function closeRuleGroupsMenu() {
ruleGroupsMenu.classList.remove('open');
ruleGroupsBox.classList.remove('active');
ruleGroupsBox.setAttribute('aria-expanded', 'false');
}
function toggleSlotSubgroupMenu(event, box) {
event.stopPropagation();
const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu');
if (!menu) return;
const isOpen = menu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active'));
if (isOpen) {
box.setAttribute('aria-expanded', 'false');
return;
}
menu.classList.add('open');
box.classList.add('active');
box.setAttribute('aria-expanded', 'true');
}
function closeSlotSubgroupMenus() {
ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open'));
ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => {
box.classList.remove('active');
box.setAttribute('aria-expanded', 'false');
});
}
function updateSlotSubgroupText(row) {
if (!row) return;
const text = row.querySelector('.slot-subgroup-box .select-text');
if (!text) return;
const selectedIds = readSlotSubgroupIds(row);
if (!selectedIds.length) {
text.textContent = 'Все выбранные группы';
return;
}
if (selectedIds.length === 1) {
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
return;
}
text.textContent = `Выбрано подгрупп: ${selectedIds.length}`;
}
function updateRuleGroupsText() {
const selectedIds = selectedRuleGroupIds();
if (!groups.length) {
ruleGroupsText.textContent = 'Нет групп';
return;
}
if (!selectedIds.length) {
ruleGroupsText.textContent = 'Выберите группы...';
return;
}
if (selectedIds.length === 1) {
const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0]));
ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа';
return;
}
ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`;
}
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 }));
});
}
}