создал систему календарного учебного графика

This commit is contained in:
Zuev
2026-04-30 23:40:49 +03:00
parent 96e9d8155f
commit 89c822a073
56 changed files with 3356 additions and 1644 deletions

View File

@@ -22,13 +22,6 @@ const SEMESTER_LABELS = {
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');
@@ -74,20 +67,29 @@ export async function initSchedule() {
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 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 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');
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 = [];
@@ -99,15 +101,19 @@ export async function initSchedule() {
let academicYears = [];
let semesters = [];
let specialties = [];
let holidays = [];
let profilesBySpecialty = new Map();
let studyForms = [];
let activityTypes = [];
let calendars = [];
let currentGridRows = [];
bindEvents();
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]);
renderRuleGroups([]);
renderRuleSlots([{}]);
await Promise.all([loadRules(), loadHolidays()]);
await Promise.all([loadRules(), loadCalendars()]);
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
}
@@ -116,15 +122,17 @@ export async function initSchedule() {
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);
holidayResetButton?.addEventListener('click', resetHolidayForm);
holidayYearSelect?.addEventListener('change', loadHolidays);
matrixLoadButton?.addEventListener('click', loadMatrix);
matrixSaveButton?.addEventListener('click', saveMatrix);
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');
@@ -134,16 +142,22 @@ export async function initSchedule() {
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);
holidayForm.addEventListener('submit', saveHoliday);
calendarForm.addEventListener('submit', saveCalendar);
rulesTbody.addEventListener('click', handleRuleTableClick);
timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick);
academicYearsTbody.addEventListener('click', handleAcademicYearTableClick);
holidaysTbody.addEventListener('click', handleHolidayTableClick);
calendarsTbody.addEventListener('click', handleCalendarTableClick);
}
async function loadBaseLists() {
@@ -155,11 +169,19 @@ export async function initSchedule() {
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 {
@@ -171,11 +193,10 @@ export async function initSchedule() {
}
function renderRules() {
if (!rules || !rules.length) {
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>
@@ -195,7 +216,6 @@ export async function initSchedule() {
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 = [
@@ -237,7 +257,6 @@ export async function initSchedule() {
async function saveRule(event) {
event.preventDefault();
hideAlert('schedule-rule-alert');
try {
const payload = buildRulePayload();
const id = ruleIdInput.value;
@@ -256,26 +275,12 @@ export async function initSchedule() {
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('Добавьте хотя бы один слот занятия');
}
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),
@@ -316,7 +321,6 @@ export async function initSchedule() {
ruleGroupsContainer.innerHTML = '<div class="loading-row">Нет групп</div>';
return;
}
const selected = new Set((selectedIds || []).map(String));
ruleGroupsContainer.innerHTML = groups.map(group => `
<label class="checkbox-item">
@@ -330,38 +334,13 @@ export async function initSchedule() {
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>
<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('');
@@ -381,7 +360,6 @@ export async function initSchedule() {
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}`);
@@ -401,11 +379,10 @@ export async function initSchedule() {
}
function renderTimeSlots() {
if (!timeSlots || !timeSlots.length) {
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>
@@ -423,7 +400,6 @@ export async function initSchedule() {
async function saveTimeSlot(event) {
event.preventDefault();
hideAlert('time-slot-alert');
const payload = {
id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null,
orderNumber: Number(timeSlotOrderInput.value),
@@ -431,10 +407,9 @@ export async function initSchedule() {
endTime: timeSlotEndInput.value,
durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null
};
try {
if (!payload.orderNumber || !payload.startTime || !payload.endTime) {
throw new Error('Заполните номер пары, начало и окончание');
throw new Error('Заполните номер пары и время');
}
const id = timeSlotIdInput.value;
await (id
@@ -443,7 +418,6 @@ export async function initSchedule() {
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
resetTimeSlotForm();
await loadTimeSlots();
renderRuleSlots(readRuleSlots(false));
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
}
@@ -452,7 +426,6 @@ export async function initSchedule() {
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;
@@ -466,7 +439,6 @@ export async function initSchedule() {
timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить временной слот?')) return;
try {
@@ -500,15 +472,14 @@ export async function initSchedule() {
}
function renderAcademicYears() {
if (!academicYears || !academicYears.length) {
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>${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>
@@ -531,17 +502,13 @@ export async function initSchedule() {
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('Заполните название и даты учебного года');
}
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value;
await (id
? api.put('/api/admin/calendar/years/' + id, payload)
@@ -549,6 +516,7 @@ export async function initSchedule() {
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
resetAcademicYearForm();
await loadYears();
await loadCalendars();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error');
}
@@ -558,7 +526,6 @@ export async function initSchedule() {
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;
@@ -571,7 +538,6 @@ export async function initSchedule() {
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return;
@@ -586,13 +552,13 @@ export async function initSchedule() {
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами?')) return;
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');
}
@@ -609,18 +575,14 @@ export async function initSchedule() {
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('Заполните учебный год, тип и даты семестра');
}
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)
@@ -641,174 +603,268 @@ export async function initSchedule() {
hideAlert('semester-alert');
}
async function saveHoliday(event) {
event.preventDefault();
hideAlert('holiday-alert');
async function loadStudyForms() {
studyForms = await api.get('/api/admin/academic-calendars/study-forms');
populateStudyFormSelect();
}
const payload = {
id: holidayIdInput.value ? Number(holidayIdInput.value) : null,
academicYearId: Number(holidayYearSelect.value),
date: holidayDateInput.value,
description: holidayDescriptionInput.value.trim()
};
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 {
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();
calendars = await api.get('/api/admin/academic-calendars');
renderCalendars();
populateEditorCalendarSelect();
} catch (error) {
showAlert('holiday-alert', error.message || 'Ошибка сохранения даты', 'error');
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
async function loadHolidays() {
const yearId = holidayYearSelect.value;
if (!yearId) {
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите учебный год</td></tr>';
function renderCalendars() {
if (!calendars.length) {
calendarsTbody.innerHTML = '<tr><td colspan="7" 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 => `
calendarsTbody.innerHTML = calendars.map(calendar => `
<tr>
<td>${escapeHtml(holiday.date)}</td>
<td>${escapeHtml(holiday.description || '-')}</td>
<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-holiday" data-id="${holiday.id}">Изменить</button>
<button class="btn-delete btn-delete-holiday" data-id="${holiday.id}">Удалить</button>
<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 handleHolidayTableClick(event) {
const editButton = event.target.closest('.btn-edit-holiday');
const deleteButton = event.target.closest('.btn-delete-holiday');
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 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' });
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;
if (!confirm('Удалить календарный график?')) return;
try {
await api.delete('/api/admin/calendar/holidays/' + deleteButton.dataset.id);
showAlert('holiday-alert', 'Дата удалена', 'success');
await loadHolidays();
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
await loadCalendars();
} catch (error) {
showAlert('holiday-alert', error.message || 'Ошибка удаления даты', 'error');
showAlert('academic-calendar-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');
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 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');
async function loadCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (!calendar) {
showAlert('calendar-editor-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');
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('matrix-alert', error.message || 'Ошибка загрузки матрицы', 'error');
showAlert('calendar-editor-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
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('matrix-alert', 'Сначала загрузите матрицу', 'error');
showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error');
return;
}
try {
await api.put('/api/admin/calendar/matrix', rows);
showAlert('matrix-alert', 'Матрица учебного графика сохранена', 'success');
await loadMatrix();
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
await loadCalendarGrid();
} catch (error) {
showAlert('matrix-alert', error.message || 'Ошибка сохранения матрицы', 'error');
showAlert('calendar-editor-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 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 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 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() {
@@ -820,26 +876,66 @@ export async function initSchedule() {
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);
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;
matrixSemesterSelect.innerHTML = semesterOptions;
syncSelects(ruleSemesterSelect, matrixSemesterSelect);
syncSelects(ruleSemesterSelect);
}
function populateSpecialtySelects() {
matrixSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
calendarSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
syncSelects(matrixSpecialtySelect);
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) {
@@ -850,8 +946,8 @@ export async function initSchedule() {
}).join('');
}
function activityOptions() {
return Object.entries(ACTIVITY_LABELS).map(([value, label]) => ({ value, label }));
function toActivityOption(type) {
return { value: type.id, label: `${type.code} - ${type.name}` };
}
function toTimeSlotOption(slot) {
@@ -871,9 +967,25 @@ export async function initSchedule() {
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})`;
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
}
function formatSemester(rule) {
@@ -888,7 +1000,7 @@ export async function initSchedule() {
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);
return name ? `${code} - ${name}` : String(code);
}
function trimTime(value) {
@@ -896,6 +1008,34 @@ export async function initSchedule() {
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 }));