feat: улучшить учебные периоды и просмотр расписаний
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user