feat: улучшить учебные периоды и просмотр расписаний

This commit is contained in:
Zuev
2026-08-15 00:20:00 +03:00
parent 855d191517
commit 52d516dd8f
22 changed files with 1107 additions and 250 deletions

View File

@@ -54,25 +54,107 @@ export function calculateCalendarTooltipPosition({
};
}
export function academicYearDefaults(startYearValue) {
const value = String(startYearValue || '').trim();
if (!/^\d{4}$/.test(value)) {
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
}
const startYear = Number(value);
if (startYear < 1000 || startYear > 9998) {
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
}
const endYear = startYear + 1;
const start = new Date(Date.UTC(startYear, 8, 1));
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
const springStart = new Date(Date.UTC(endYear, 1, 1));
const end = new Date(Date.UTC(endYear, 5, 30));
return {
startYear,
endYear,
title: `${startYear}-${endYear}`,
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(end),
semesters: [
{
semesterType: 'autumn',
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(autumnEnd)
},
{
semesterType: 'spring',
startDate: toIsoDateUtc(springStart),
endDate: toIsoDateUtc(end)
}
]
};
}
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
if (!Array.isArray(semesters) || semesters.length === 0) {
throw new Error('Учебный год нельзя создать без семестров');
}
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
const types = new Set();
sorted.forEach((semester, index) => {
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
throw new Error('Заполните тип и даты каждого семестра');
}
if (types.has(semester.semesterType)) {
throw new Error('Типы семестров не должны повторяться');
}
types.add(semester.semesterType);
if (semester.endDate < semester.startDate) {
throw new Error('Дата окончания семестра не может быть раньше даты начала');
}
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
throw new Error('Семестры должны находиться в границах учебного года');
}
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
throw new Error('Периоды семестров не должны пересекаться');
}
});
}
function toIsoDateUtc(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
export function activityColorCode(activity) {
const color = String(activity?.colorCode || '').trim();
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
}
export function activityColorClass(activity) {
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
}
export async function initAcademicCalendar() {
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 academicYearSemesterInputs = {
autumn: {
id: document.getElementById('academic-year-autumn-id'),
start: document.getElementById('academic-year-autumn-start'),
end: document.getElementById('academic-year-autumn-end')
},
spring: {
id: document.getElementById('academic-year-spring-id'),
start: document.getElementById('academic-year-spring-start'),
end: document.getElementById('academic-year-spring-end')
}
};
const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
@@ -119,7 +201,6 @@ export async function initAcademicCalendar() {
const subjectCount = document.getElementById('calendar-subject-count');
let academicYears = [];
let semesters = [];
let specialties = [];
let profilesBySpecialty = new Map();
let studyForms = [];
@@ -158,10 +239,10 @@ export async function initAcademicCalendar() {
function bindEvents() {
[
academicYearStartInput,
academicYearEndInput,
semesterStartInput,
semesterEndInput,
academicYearSemesterInputs.autumn.start,
academicYearSemesterInputs.autumn.end,
academicYearSemesterInputs.spring.start,
academicYearSemesterInputs.spring.end,
fillStartInput,
fillEndInput
].forEach(bindDateInputMask);
@@ -172,7 +253,7 @@ export async function initAcademicCalendar() {
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
semesterResetButton?.addEventListener('click', resetSemesterForm);
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
calendarResetButton?.addEventListener('click', resetCalendarForm);
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
editorLoadButton?.addEventListener('click', loadCalendarGrid);
@@ -244,7 +325,6 @@ export async function initAcademicCalendar() {
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
academicYearForm?.addEventListener('submit', saveAcademicYear);
semesterForm?.addEventListener('submit', saveSemester);
calendarForm?.addEventListener('submit', saveCalendar);
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
@@ -269,7 +349,6 @@ export async function initAcademicCalendar() {
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();
} catch (error) {
@@ -319,17 +398,19 @@ export async function initAcademicCalendar() {
event.preventDefault();
hideAlert('academic-year-alert');
try {
const defaults = applyAcademicYearDateDefaults(true);
const { startDate, endDate } = defaults;
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
startDate,
endDate,
semesters: ['autumn', 'spring'].map(type => readAcademicYearSemester(type))
};
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
validateAcademicYearSemesters(payload.semesters, startDate, endDate);
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');
showAlert('academic-year-alert', 'Учебный год и семестры сохранены', 'success');
resetAcademicYearForm();
await loadYears();
await loadCalendars();
@@ -346,28 +427,16 @@ export async function initAcademicCalendar() {
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 || '';
setDateInputIso(academicYearStartInput, year.startDate);
setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
fillAcademicYearForm(year);
return;
}
if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return;
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
setDateInputIso(semesterStartInput, semester.startDate);
setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
const year = academicYears.find(item => (item.semesters || [])
.some(semester => String(semester.id) === String(editSemesterButton.dataset.id)));
if (!year) return;
const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id));
fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType));
return;
}
@@ -386,42 +455,73 @@ export async function initAcademicCalendar() {
function resetAcademicYearForm() {
academicYearForm.reset();
academicYearFormTitle.textContent = 'Учебный год';
academicYearFormTitle.textContent = 'Учебный год и семестры';
academicYearIdInput.value = '';
Object.values(academicYearSemesterInputs).forEach(inputs => {
inputs.id.value = '';
});
hideAlert('academic-year-alert');
}
async function saveSemester(event) {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
try {
const payload = {
semesterType: semesterTypeSelect.value,
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
};
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 fillAcademicYearForm(year, focusedSemesterType = '') {
academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim();
academicYearIdInput.value = year.id;
academicYearStartInput.value = String(year.startDate || '').slice(0, 4);
academicYearEndInput.value = String(year.endDate || '').slice(0, 4);
['autumn', 'spring'].forEach(type => {
const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type);
const inputs = academicYearSemesterInputs[type];
inputs.id.value = semester?.id || '';
setDateInputIso(inputs.start, semester?.startDate || '');
setDateInputIso(inputs.end, semester?.endDate || '');
});
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) {
academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true });
}
}
function resetSemesterForm() {
semesterForm.reset();
semesterFormTitle.textContent = 'Семестр';
semesterIdInput.value = '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
function handleAcademicYearStartInput() {
academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4);
if (academicYearStartInput.value.length !== 4) {
academicYearEndInput.value = '';
return;
}
try {
applyAcademicYearDateDefaults();
} catch (_) {
academicYearEndInput.value = '';
}
}
function applyAcademicYearDateDefaults(preserveSemesterDates = false) {
const defaults = academicYearDefaults(academicYearStartInput.value);
academicYearEndInput.value = String(defaults.endYear);
const semesterDefaults = {
autumn: defaults.semesters[0],
spring: defaults.semesters[1]
};
['autumn', 'spring'].forEach(type => {
const inputs = academicYearSemesterInputs[type];
if (!preserveSemesterDates || !inputs.start.value) {
setDateInputIso(inputs.start, semesterDefaults[type].startDate);
}
if (!preserveSemesterDates || !inputs.end.value) {
setDateInputIso(inputs.end, semesterDefaults[type].endDate);
}
});
return defaults;
}
function readAcademicYearSemester(type) {
const inputs = academicYearSemesterInputs[type];
return {
id: inputs.id.value ? Number(inputs.id.value) : null,
semesterType: type,
startDate: readDateInputIso(inputs.start, `Начало ${SEMESTER_LABELS[type]} семестра`),
endDate: readDateInputIso(inputs.end, `Окончание ${SEMESTER_LABELS[type]} семестра`)
};
}
async function loadStudyForms() {
@@ -895,7 +995,7 @@ export async function initAcademicCalendar() {
const activity = activityById(selected) || activityTypes[0] || {};
const code = activity.code || row.activityCode || 'Т';
return `
<td class="calendar-day"
<td class="calendar-day ${activityColorClass(activity)}"
data-course="${row.courseNumber}"
data-date="${row.date}"
data-week="${row.weekNumber}"
@@ -967,9 +1067,11 @@ export async function initAcademicCalendar() {
function updateCalendarDayDialogSummary(day) {
const activity = activityById(day.querySelector('.calendar-day-activity').value);
if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = '';
return;
}
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
@@ -987,6 +1089,7 @@ export async function initAcademicCalendar() {
const activity = activityById(commonCalendarDayActivityValue(days));
if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">...</span>
<div>
@@ -997,6 +1100,7 @@ export async function initAcademicCalendar() {
return;
}
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
@@ -1091,6 +1195,7 @@ export async function initAcademicCalendar() {
const select = day.querySelector('.calendar-day-activity');
const activity = activityById(select.value);
const code = activity?.code || '-';
applyActivityColorClass(day, activity);
day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
}
@@ -1102,6 +1207,8 @@ export async function initAcademicCalendar() {
}
const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day);
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
applyActivityColorClass(tooltip, activity);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
@@ -1285,7 +1392,9 @@ export async function initAcademicCalendar() {
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);
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
});
const selectedDays = selectedCalendarDays.length
? selectedCalendarDays
@@ -1296,19 +1405,18 @@ export async function initAcademicCalendar() {
const selectedCode = selectedActivity?.code || null;
calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => {
.map(([code, entry]) => {
const selectedClass = selectedCode === code ? ' is-selected' : '';
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
return `<span class="calendar-total-chip${selectedClass}">${escapeHtml(code)}: ${count}${suffix}</span>`;
return `<span class="calendar-total-chip ${entry.colorClass}${selectedClass}">${escapeHtml(code)}: ${entry.count}${suffix}</span>`;
}).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);
syncSelects(calendarYearSelect);
}
function populateSpecialtySelects() {
@@ -1474,6 +1582,16 @@ export async function initAcademicCalendar() {
return activityTypes.find(type => String(type.id) === String(id));
}
function applyActivityColorClass(element, activity) {
if (!element) return;
Array.from(element.classList)
.filter(className => className.startsWith('calendar-activity-color-'))
.forEach(className => element.classList.remove(className));
if (activity) {
element.classList.add(activityColorClass(activity));
}
}
function timeAssignmentForDate(date) {
return timeSlotDateAssignments.find(item => item.date === date);
}