1045 lines
50 KiB
JavaScript
1045 lines
50 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');
|
||
|
||
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 calendarForm = document.getElementById('academic-calendar-form');
|
||
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
|
||
const calendarIdInput = document.getElementById('academic-calendar-id');
|
||
const calendarTitleInput = document.getElementById('academic-calendar-title');
|
||
const calendarYearSelect = document.getElementById('academic-calendar-year');
|
||
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
|
||
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
|
||
const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form');
|
||
const calendarCourseCountInput = document.getElementById('academic-calendar-course-count');
|
||
const calendarResetButton = document.getElementById('academic-calendar-reset');
|
||
const calendarsRefreshButton = document.getElementById('academic-calendars-refresh');
|
||
const calendarsTbody = document.getElementById('academic-calendars-tbody');
|
||
|
||
const editorCalendarSelect = document.getElementById('calendar-editor-calendar');
|
||
const editorLoadButton = document.getElementById('calendar-editor-load');
|
||
const editorSaveButton = document.getElementById('calendar-editor-save');
|
||
const fillCourseSelect = document.getElementById('calendar-fill-course');
|
||
const fillStartInput = document.getElementById('calendar-fill-start');
|
||
const fillEndInput = document.getElementById('calendar-fill-end');
|
||
const fillActivitySelect = document.getElementById('calendar-fill-activity');
|
||
const fillApplyButton = document.getElementById('calendar-fill-apply');
|
||
const calendarGrid = document.getElementById('calendar-grid');
|
||
const calendarTotals = document.getElementById('calendar-totals');
|
||
|
||
let rules = [];
|
||
let subjects = [];
|
||
let groups = [];
|
||
let teachers = [];
|
||
let classrooms = [];
|
||
let lessonTypes = [];
|
||
let timeSlots = [];
|
||
let academicYears = [];
|
||
let semesters = [];
|
||
let specialties = [];
|
||
let profilesBySpecialty = new Map();
|
||
let studyForms = [];
|
||
let activityTypes = [];
|
||
let calendars = [];
|
||
let currentGridRows = [];
|
||
|
||
bindEvents();
|
||
|
||
try {
|
||
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]);
|
||
renderRuleGroups([]);
|
||
renderRuleSlots([{}]);
|
||
await Promise.all([loadRules(), loadCalendars()]);
|
||
} catch (error) {
|
||
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
|
||
}
|
||
|
||
function bindEvents() {
|
||
refreshRulesButton?.addEventListener('click', loadRules);
|
||
refreshSlotsButton?.addEventListener('click', loadTimeSlots);
|
||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||
timeSlotResetButton?.addEventListener('click', resetTimeSlotForm);
|
||
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
||
semesterResetButton?.addEventListener('click', resetSemesterForm);
|
||
calendarResetButton?.addEventListener('click', resetCalendarForm);
|
||
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
|
||
editorLoadButton?.addEventListener('click', loadCalendarGrid);
|
||
editorSaveButton?.addEventListener('click', saveCalendarGrid);
|
||
fillApplyButton?.addEventListener('click', applyCalendarFill);
|
||
|
||
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 : [{}]);
|
||
});
|
||
|
||
calendarGrid.addEventListener('change', (event) => {
|
||
if (event.target.matches('.calendar-day-activity')) {
|
||
renderCalendarTotals();
|
||
}
|
||
});
|
||
|
||
ruleForm.addEventListener('submit', saveRule);
|
||
timeSlotForm.addEventListener('submit', saveTimeSlot);
|
||
academicYearForm.addEventListener('submit', saveAcademicYear);
|
||
semesterForm.addEventListener('submit', saveSemester);
|
||
calendarForm.addEventListener('submit', saveCalendar);
|
||
|
||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||
timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick);
|
||
academicYearsTbody.addEventListener('click', handleAcademicYearTableClick);
|
||
calendarsTbody.addEventListener('click', handleCalendarTableClick);
|
||
}
|
||
|
||
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')
|
||
]);
|
||
await loadProfiles();
|
||
populateSubjectSelects();
|
||
populateSpecialtySelects();
|
||
}
|
||
|
||
async function loadProfiles() {
|
||
const pairs = await Promise.all(specialties.map(async (speciality) => {
|
||
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
|
||
return [String(speciality.id), profiles];
|
||
}));
|
||
profilesBySpecialty = new Map(pairs);
|
||
}
|
||
|
||
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() {
|
||
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.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();
|
||
} 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.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();
|
||
await loadCalendars();
|
||
} 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();
|
||
await loadCalendars();
|
||
} 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 loadStudyForms() {
|
||
studyForms = await api.get('/api/admin/academic-calendars/study-forms');
|
||
populateStudyFormSelect();
|
||
}
|
||
|
||
async function loadActivityTypes() {
|
||
activityTypes = await api.get('/api/admin/calendar/activity-types');
|
||
populateActivitySelects();
|
||
}
|
||
|
||
async function loadCalendars() {
|
||
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||
try {
|
||
calendars = await api.get('/api/admin/academic-calendars');
|
||
renderCalendars();
|
||
populateEditorCalendarSelect();
|
||
} catch (error) {
|
||
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderCalendars() {
|
||
if (!calendars.length) {
|
||
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>';
|
||
return;
|
||
}
|
||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||
<tr>
|
||
<td>${escapeHtml(calendar.title)}</td>
|
||
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
|
||
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
|
||
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
|
||
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
|
||
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
|
||
<td>
|
||
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
async function saveCalendar(event) {
|
||
event.preventDefault();
|
||
hideAlert('academic-calendar-alert');
|
||
const payload = {
|
||
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
|
||
title: calendarTitleInput.value.trim(),
|
||
academicYearId: Number(calendarYearSelect.value),
|
||
specialtyId: Number(calendarSpecialtySelect.value),
|
||
specialtyProfileId: Number(calendarProfileSelect.value),
|
||
studyFormId: Number(calendarStudyFormSelect.value),
|
||
courseCount: Number(calendarCourseCountInput.value)
|
||
};
|
||
try {
|
||
if (!payload.title || !payload.academicYearId || !payload.specialtyId
|
||
|| !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) {
|
||
throw new Error('Заполните все поля календарного графика');
|
||
}
|
||
const id = calendarIdInput.value;
|
||
const saved = id
|
||
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
||
: await api.post('/api/admin/academic-calendars', payload);
|
||
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success');
|
||
resetCalendarForm();
|
||
await loadCalendars();
|
||
} catch (error) {
|
||
showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleCalendarTableClick(event) {
|
||
const editButton = event.target.closest('.btn-edit-calendar');
|
||
const gridButton = event.target.closest('.btn-open-calendar-grid');
|
||
const deleteButton = event.target.closest('.btn-delete-calendar');
|
||
if (editButton) {
|
||
const calendar = calendars.find(item => item.id == editButton.dataset.id);
|
||
if (!calendar) return;
|
||
calendarFormTitle.textContent = 'Редактирование календарного графика';
|
||
calendarIdInput.value = calendar.id;
|
||
calendarTitleInput.value = calendar.title || '';
|
||
calendarYearSelect.value = calendar.academicYearId || '';
|
||
calendarSpecialtySelect.value = calendar.specialtyId || '';
|
||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
|
||
calendarStudyFormSelect.value = calendar.studyFormId || '';
|
||
calendarCourseCountInput.value = calendar.courseCount || 4;
|
||
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
|
||
hideAlert('academic-calendar-alert');
|
||
calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
if (gridButton) {
|
||
editorCalendarSelect.value = gridButton.dataset.id;
|
||
syncSelects(editorCalendarSelect);
|
||
await loadCalendarGrid();
|
||
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
if (deleteButton) {
|
||
if (!confirm('Удалить календарный график?')) return;
|
||
try {
|
||
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
|
||
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
||
await loadCalendars();
|
||
} catch (error) {
|
||
showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
function resetCalendarForm() {
|
||
calendarForm.reset();
|
||
calendarFormTitle.textContent = 'Календарный учебный график';
|
||
calendarIdInput.value = '';
|
||
calendarCourseCountInput.value = 4;
|
||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
|
||
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
|
||
hideAlert('academic-calendar-alert');
|
||
}
|
||
|
||
async function loadCalendarGrid() {
|
||
hideAlert('calendar-editor-alert');
|
||
const calendar = selectedEditorCalendar();
|
||
if (!calendar) {
|
||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
|
||
currentGridRows = rows.length ? rows : buildDefaultGrid(calendar);
|
||
renderCalendarGrid(currentGridRows);
|
||
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
|
||
} catch (error) {
|
||
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
|
||
}
|
||
}
|
||
|
||
async function saveCalendarGrid() {
|
||
hideAlert('calendar-editor-alert');
|
||
const calendar = selectedEditorCalendar();
|
||
if (!calendar) {
|
||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||
return;
|
||
}
|
||
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
|
||
calendarId: calendar.id,
|
||
courseNumber: Number(day.dataset.course),
|
||
date: day.dataset.date,
|
||
weekNumber: Number(day.dataset.week),
|
||
dayOfWeek: Number(day.dataset.day),
|
||
activityTypeId: Number(day.querySelector('.calendar-day-activity').value)
|
||
}));
|
||
if (!rows.length) {
|
||
showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
|
||
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
|
||
await loadCalendarGrid();
|
||
} catch (error) {
|
||
showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error');
|
||
}
|
||
}
|
||
|
||
function applyCalendarFill() {
|
||
hideAlert('calendar-editor-alert');
|
||
const activityId = fillActivitySelect.value;
|
||
const start = fillStartInput.value;
|
||
const end = fillEndInput.value;
|
||
if (!activityId || !start || !end) {
|
||
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
|
||
return;
|
||
}
|
||
if (end < start) {
|
||
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
|
||
return;
|
||
}
|
||
const course = fillCourseSelect.value;
|
||
let changed = 0;
|
||
calendarGrid.querySelectorAll('.calendar-day').forEach(day => {
|
||
const sameCourse = !course || day.dataset.course === course;
|
||
const inRange = day.dataset.date >= start && day.dataset.date <= end;
|
||
if (sameCourse && inRange) {
|
||
day.querySelector('.calendar-day-activity').value = activityId;
|
||
changed += 1;
|
||
}
|
||
});
|
||
renderCalendarTotals();
|
||
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
|
||
}
|
||
|
||
function renderCalendarGrid(rows) {
|
||
const grouped = groupBy(rows, row => row.courseNumber);
|
||
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
|
||
<section class="calendar-course-block">
|
||
<div class="calendar-course-header">
|
||
<h3>${course} курс</h3>
|
||
<span>${courseRows.length} дней</span>
|
||
</div>
|
||
<div class="calendar-days-grid">
|
||
${courseRows.map(renderCalendarDay).join('')}
|
||
</div>
|
||
</section>
|
||
`).join('');
|
||
populateFillCourseSelect();
|
||
renderCalendarTotals();
|
||
}
|
||
|
||
function renderCalendarDay(row) {
|
||
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
|
||
const dayName = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'][Number(row.dayOfWeek) - 1] || '';
|
||
return `
|
||
<div class="calendar-day"
|
||
data-course="${row.courseNumber}"
|
||
data-date="${row.date}"
|
||
data-week="${row.weekNumber}"
|
||
data-day="${row.dayOfWeek}">
|
||
<div class="calendar-day-meta">
|
||
<span>${formatShortDate(row.date)}</span>
|
||
<span>${dayName}</span>
|
||
</div>
|
||
<select class="calendar-day-activity">${options(activityTypes.map(toActivityOption), selected)}</select>
|
||
<span class="calendar-week-label">${row.weekNumber} нед.</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function buildDefaultGrid(calendar) {
|
||
const rows = [];
|
||
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
|
||
for (let course = 1; course <= calendar.courseCount; course += 1) {
|
||
dates.forEach((date, index) => {
|
||
rows.push({
|
||
id: null,
|
||
calendarId: calendar.id,
|
||
courseNumber: course,
|
||
date: date.iso,
|
||
weekNumber: Math.floor(index / 7) + 1,
|
||
dayOfWeek: date.dayOfWeek,
|
||
activityTypeId: theoryActivityId(),
|
||
activityCode: 'Т'
|
||
});
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function renderCalendarTotals() {
|
||
const counts = new Map();
|
||
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
|
||
const activity = activityById(select.value);
|
||
const key = activity ? activity.code : '-';
|
||
counts.set(key, (counts.get(key) || 0) + 1);
|
||
});
|
||
calendarTotals.innerHTML = Array.from(counts.entries())
|
||
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
|
||
.map(([code, count]) => {
|
||
const activity = activityTypes.find(item => item.code === code);
|
||
return `<span class="calendar-total-chip" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}</span>`;
|
||
}).join('');
|
||
}
|
||
|
||
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;
|
||
calendarYearSelect.innerHTML = yearOptions;
|
||
syncSelects(semesterYearSelect, calendarYearSelect);
|
||
}
|
||
|
||
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 populateSpecialtySelects() {
|
||
calendarSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
|
||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
|
||
syncSelects(calendarSpecialtySelect);
|
||
}
|
||
|
||
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
|
||
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
|
||
select.innerHTML = profiles.length
|
||
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
|
||
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
|
||
).join('')
|
||
: '<option value="">Нет профилей</option>';
|
||
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
|
||
select.value = selectedProfileId;
|
||
}
|
||
syncSelects(select);
|
||
}
|
||
|
||
function populateStudyFormSelect() {
|
||
calendarStudyFormSelect.innerHTML = '<option value="">Выберите форму</option>' +
|
||
studyForms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||
syncSelects(calendarStudyFormSelect);
|
||
}
|
||
|
||
function populateActivitySelects() {
|
||
const activityOptions = '<option value="">Выберите код</option>' +
|
||
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
|
||
fillActivitySelect.innerHTML = activityOptions;
|
||
syncSelects(fillActivitySelect);
|
||
}
|
||
|
||
function populateEditorCalendarSelect() {
|
||
const current = editorCalendarSelect.value;
|
||
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
||
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join('');
|
||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||
editorCalendarSelect.value = current;
|
||
}
|
||
syncSelects(editorCalendarSelect);
|
||
}
|
||
|
||
function populateFillCourseSelect() {
|
||
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
|
||
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
|
||
Array.from(courses).sort((a, b) => Number(a) - Number(b))
|
||
.map(course => `<option value="${course}">${course} курс</option>`).join('');
|
||
syncSelects(fillCourseSelect);
|
||
}
|
||
|
||
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 toActivityOption(type) {
|
||
return { value: type.id, label: `${type.code} - ${type.name}` };
|
||
}
|
||
|
||
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 selectedEditorCalendar() {
|
||
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
|
||
}
|
||
|
||
function theoryActivityId() {
|
||
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
|
||
}
|
||
|
||
function activityIdByCode(code) {
|
||
return activityTypes.find(type => type.code === code)?.id || null;
|
||
}
|
||
|
||
function activityById(id) {
|
||
return activityTypes.find(type => String(type.id) === String(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 formatShortDate(value) {
|
||
const [year, month, day] = String(value).split('-');
|
||
return `${day}.${month}`;
|
||
}
|
||
|
||
function enumerateDates(startIso, endIso) {
|
||
const result = [];
|
||
const current = new Date(`${startIso}T00:00:00Z`);
|
||
const end = new Date(`${endIso}T00:00:00Z`);
|
||
while (current <= end) {
|
||
const iso = current.toISOString().slice(0, 10);
|
||
const jsDay = current.getUTCDay();
|
||
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
|
||
current.setUTCDate(current.getUTCDate() + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function groupBy(items, keyFn) {
|
||
const grouped = new Map();
|
||
items.forEach(item => {
|
||
const key = keyFn(item);
|
||
if (!grouped.has(key)) grouped.set(key, []);
|
||
grouped.get(key).push(item);
|
||
});
|
||
return grouped;
|
||
}
|
||
|
||
function syncSelects(...selects) {
|
||
selects.filter(Boolean).forEach(select => {
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
}
|
||
}
|