Files
magistr/frontend/react/admin/academic-calendar-service.js
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

642 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiClient } from '../shared/api-client.js';
import { dateInputValueToIso, isoDateToInputValue } from '../../admin/js/date-input.js';
import { academicCalendarWeekNumber } from '../../admin/js/views/academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
export const DAY_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: 'Понедельник', shortLabel: 'Пн' }),
Object.freeze({ value: 2, label: 'Вторник', shortLabel: 'Вт' }),
Object.freeze({ value: 3, label: 'Среда', shortLabel: 'Ср' }),
Object.freeze({ value: 4, label: 'Четверг', shortLabel: 'Чт' }),
Object.freeze({ value: 5, label: 'Пятница', shortLabel: 'Пт' }),
Object.freeze({ value: 6, label: 'Суббота', shortLabel: 'Сб' }),
Object.freeze({ value: 7, label: 'Воскресенье', shortLabel: 'Вс' })
]);
export const SEMESTER_LABELS = Object.freeze({
autumn: 'осенний',
spring: 'весенний'
});
const CALENDAR_TOOLTIP_OFFSET = 14;
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
// --- Позиция подсказки ячейки ---
export function calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth,
tooltipHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
const maxTop = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
if (left > maxLeft) {
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
}
if (top > maxTop) {
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
}
return {
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
// --- Учебный год ---
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('-');
}
// Ввод года начала: нормализует значение и пересчитывает год окончания
// вместе с датами семестров. Как в legacy, ручной ввод года сбрасывает даты
// семестров к рассчитанным значениям (semesterDates === null — не трогать).
export function startYearInputResult(rawValue) {
const startYear = String(rawValue || '').replace(/\D/g, '').slice(0, 4);
if (startYear.length !== 4) {
return { startYear, endYear: '', semesterDates: null };
}
try {
const defaults = academicYearDefaults(startYear);
return {
startYear,
endYear: String(defaults.endYear),
semesterDates: {
autumn: {
start: isoDateToInputValue(defaults.semesters[0].startDate),
end: isoDateToInputValue(defaults.semesters[0].endDate)
},
spring: {
start: isoDateToInputValue(defaults.semesters[1].startDate),
end: isoDateToInputValue(defaults.semesters[1].endDate)
}
}
};
} catch {
return { startYear, endYear: '', semesterDates: null };
}
}
// Чтение даты из поля ДД.ММ.ГГГГ с подписью поля в сообщении об ошибке
// (реплика legacy readDateInputIso).
export function readDisplayDateIso(displayValue, label = 'Дата') {
try {
return dateInputValueToIso(displayValue);
} catch (error) {
throw new Error(`${label}: ${lowercaseFirst(error.message)}`);
}
}
// --- Типы активности ---
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 function activityById(id, activityTypes) {
return (activityTypes || []).find(type => String(type.id) === String(id));
}
export function activityIdByCode(code, activityTypes) {
return (activityTypes || []).find(type => type.code === code)?.id || null;
}
export function theoryActivityId(activityTypes) {
return (activityTypes || []).find(type => type.code === 'Т')?.id || activityTypes?.[0]?.id || '';
}
// Эффективный код активности ячейки: сохранённое значение, затем код по
// справочнику, затем теория (реплика legacy renderCalendarDay).
export function effectiveActivityTypeId(row, activityTypes) {
return row.activityTypeId || activityIdByCode(row.activityCode, activityTypes) || theoryActivityId(activityTypes);
}
// --- Контракты API ---
export function fetchSpecialties(client = apiClient) {
return client.get('/api/specialties', { errorMessage: 'Ошибка загрузки специальностей' });
}
export function specialtyProfilesEndpoint(specialtyId) {
return `/api/specialties/${specialtyId}/profiles`;
}
export function fetchSpecialtyProfiles(specialtyId, client = apiClient) {
return client.get(specialtyProfilesEndpoint(specialtyId), {
errorMessage: 'Ошибка загрузки профилей специальности'
});
}
export function fetchAcademicYearsList(client = apiClient) {
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
}
export function fetchSubjectsList(client = apiClient) {
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
}
export function fetchEducationForms(client = apiClient) {
return client.get('/api/education-forms', { errorMessage: 'Ошибка загрузки форм обучения' });
}
export function fetchActivityTypes(client = apiClient) {
return client.get('/api/admin/calendar/activity-types', {
errorMessage: 'Ошибка загрузки типов активности'
});
}
export function fetchTimeSlotScopes(client = apiClient) {
return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Ошибка загрузки сеток времени' });
}
export function fetchTimeSlotDateAssignments(client = apiClient) {
return client.get('/api/admin/time-slots/date-assignments', {
errorMessage: 'Ошибка загрузки ручных сеток'
});
}
export function calendarsPageEndpoint({ page, size, query, academicYearId } = {}) {
const params = new URLSearchParams({
page: String(page ?? 0),
size: String(size ?? 25),
query: String(query || '').trim()
});
if (academicYearId) params.set('academicYearId', String(academicYearId));
return `/api/admin/academic-calendars/page?${params}`;
}
export function fetchCalendarsPage({ page, size, query, academicYearId }, client = apiClient) {
return client.get(calendarsPageEndpoint({ page, size, query, academicYearId }), {
errorMessage: 'Ошибка загрузки календарных графиков'
});
}
export function academicCalendarsOptionsEndpoint(query, selectedId) {
const params = new URLSearchParams({ query: String(query || ''), limit: '20' });
if (selectedId) params.set('selectedId', String(selectedId));
return `/api/admin/academic-calendars/options?${params}`;
}
export function fetchCalendarOptions(query, selectedId, client = apiClient) {
return client.get(academicCalendarsOptionsEndpoint(query, selectedId), {
errorMessage: 'Ошибка поиска календарных графиков'
});
}
export function saveAcademicYearRequest(payload, id, client = apiClient) {
return id
? client.put(`/api/admin/calendar/years/${id}`, payload, { errorMessage: 'Ошибка сохранения учебного года' })
: client.post('/api/admin/calendar/years', payload, { errorMessage: 'Ошибка сохранения учебного года' });
}
export function deleteAcademicYearRequest(id, client = apiClient) {
return client.delete(`/api/admin/calendar/years/${id}`, {
errorMessage: 'Ошибка удаления учебного года'
});
}
export function saveCalendarRequest(payload, id, client = apiClient) {
return id
? client.put(`/api/admin/academic-calendars/${id}`, payload, { errorMessage: 'Ошибка сохранения календарного графика' })
: client.post('/api/admin/academic-calendars', payload, { errorMessage: 'Ошибка сохранения календарного графика' });
}
export function deleteCalendarRequest(id, client = apiClient) {
return client.delete(`/api/admin/academic-calendars/${id}`, {
errorMessage: 'Ошибка удаления календарного графика'
});
}
export function calendarSubjectsEndpoint(calendarId) {
return `/api/admin/academic-calendars/${calendarId}/subjects`;
}
export function fetchCalendarSubjects(calendarId, client = apiClient) {
return client.get(calendarSubjectsEndpoint(calendarId), {
errorMessage: 'Ошибка загрузки дисциплин графика'
});
}
export function saveCalendarSubjectsRequest(calendarId, payload, client = apiClient) {
return client.put(calendarSubjectsEndpoint(calendarId), payload, {
errorMessage: 'Ошибка сохранения дисциплин графика'
});
}
export function calendarGridEndpoint(calendarId) {
return `/api/admin/academic-calendars/${calendarId}/grid`;
}
export function fetchCalendarGrid(calendarId, client = apiClient) {
return client.get(calendarGridEndpoint(calendarId), { errorMessage: 'Ошибка загрузки сетки' });
}
export function saveCalendarGridRequest(calendarId, rows, client = apiClient) {
return client.put(calendarGridEndpoint(calendarId), rows, {
errorMessage: 'Ошибка сохранения сетки'
});
}
export function deleteTimeSlotDateAssignmentRequest(id, client = apiClient) {
return client.delete(`/api/admin/time-slots/date-assignments/${id}`, {
errorMessage: 'Ошибка удаления ручной сетки'
});
}
// --- Подписи ---
export function calendarOptionLabel(calendar) {
return buildAcademicCalendarTitle(calendar) || calendar.title;
}
export function calendarOptionDescription(calendar) {
return calendar.specialtyName || '';
}
export function specialityLabel(speciality) {
const code = specialityCode(speciality) || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code);
}
export function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_code
|| '';
}
export function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
}
export function shortDayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
}
export function formatShortDate(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
export function formatLongDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function formatDisplayDate(value) {
const [year, month, day] = String(value || '').split('-');
if (!year || !month || !day) return String(value || '');
return `${day}-${month}-${year}`;
}
export function formatDisplayDateRange(startDate, endDate) {
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
}
export function dateRangeCaption(rows) {
if (!rows.length) return '';
const dates = rows.map(row => row.date).sort();
return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
}
export function semesterTypeTitle(semester) {
const type = normalizeSemesterType(semester.semesterType);
const label = SEMESTER_LABELS[type] || type || 'семестр';
return label.charAt(0).toUpperCase() + label.slice(1);
}
export function normalizeSemesterType(type) {
return String(type || '').toLowerCase();
}
export function semesterOptionLabel(semesterNumber) {
const course = Math.ceil(Number(semesterNumber) / 2);
const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний';
return `${semesterNumber} семестр · ${course} курс, ${type}`;
}
export function subjectLabel(subject) {
const code = subject.code ? `${subject.code} · ` : '';
return `${code}${subject.name || 'Без названия'}`;
}
export function subjectRowLabel(row) {
const code = row.subjectCode ? `${row.subjectCode} · ` : '';
return `${code}${row.subjectName || row.subjectId || 'Без названия'}`;
}
export function sortCalendarSubjectRows(rows) {
return [...(rows || [])].sort((a, b) => {
const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber);
if (semesterDiff !== 0) return semesterDiff;
return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru');
});
}
export function calendarSemesterCount(calendar) {
return Math.max(1, Number(calendar?.courseCount || 0) * 2);
}
// --- Сетка годового графика ---
export 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;
}
export function calendarRowKey(row) {
return `${row.courseNumber}:${row.date}`;
}
export function uniqueWeekNumbers(rows) {
return Array.from(new Set((rows || []).map(row => row.weekNumber)));
}
export function minIso(a, b) {
return String(a) <= String(b) ? a : b;
}
export function maxIso(a, b) {
return String(a) >= String(b) ? a : b;
}
export function previousIsoDate(value) {
const date = parseLocalIsoDate(value);
date.setDate(date.getDate() - 1);
return formatLocalDate(date);
}
export function enumerateDates(startIso, endIso) {
const result = [];
const current = parseLocalIsoDate(startIso);
const end = parseLocalIsoDate(endIso);
while (current <= end) {
const iso = formatLocalDate(current);
const jsDay = current.getDay();
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
current.setDate(current.getDate() + 1);
}
return result;
}
function parseLocalIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(year, month - 1, day, 12);
if (!year || !month || !day || Number.isNaN(date.getTime())
|| date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
return date;
}
function formatLocalDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
export function buildDefaultGrid(calendar, activityTypes) {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach(date => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(activityTypes),
activityCode: 'Т'
});
});
}
return rows;
}
export function semesterBlocksForCourse(calendar, course, courseRows, academicYears) {
const sortedRows = [...(courseRows || [])].sort((a, b) => String(a.date).localeCompare(String(b.date)));
const year = (academicYears || []).find(item => String(item.id) === String(calendar?.academicYearId));
const yearSemesters = [...(year?.semesters || [])]
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
if (!yearSemesters.length) {
return [{
semesterNumber: `${course * 2 - 1}-${course * 2}`,
caption: dateRangeCaption(sortedRows),
rows: sortedRows
}];
}
return yearSemesters.map((semester, index) => {
const nextSemester = yearSemesters[index + 1];
const startDate = index === 0
? minIso(calendar.academicYearStartDate, semester.startDate)
: semester.startDate;
const endDate = nextSemester
? previousIsoDate(nextSemester.startDate)
: maxIso(calendar.academicYearEndDate, semester.endDate);
const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate);
return {
semesterNumber: course * 2 - 1 + index,
caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`,
rows
};
});
}
// Итоговые чипы сетки: счётчики по кодам активности, сортировка по коду.
export function gridTotalsEntries(rows, activityTypes) {
const counts = new Map();
(rows || []).forEach(row => {
const activity = activityById(row.activityTypeId, activityTypes);
const key = activity ? activity.code : '-';
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
});
return Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, entry]) => ({ code, count: entry.count, colorClass: entry.colorClass }));
}
// Общий код активности набора ячеек: одно значение — оно само, иначе пусто.
export function commonActivityValue(rows) {
const values = Array.from(new Set((rows || []).map(row => String(row.activityTypeId ?? ''))));
return values.length === 1 ? values[0] : '';
}
// --- Ручные сетки дат ---
export function timeAssignmentForDate(assignments, date) {
return (assignments || []).find(item => item.date === date);
}
export function automaticTimeScopeLabel(date, scopes) {
const jsDate = new Date(`${date}T00:00:00`);
const jsDay = jsDate.getDay();
const isoDay = jsDay === 0 ? 7 : jsDay;
const weekdayScope = (scopes || []).find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay);
const defaultScope = (scopes || []).find(scope => scope.applyMode === 'DEFAULT');
const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка';
return `По правилу даты (${scopeName})`;
}
// --- Тексты диалога ячейки ---
export function dayDialogTexts(rows) {
const dates = rows.map(row => row.date).sort();
const courses = new Set(rows.map(row => String(row.courseNumber)));
const weeks = new Set(rows.map(row => String(row.weekNumber)));
const weekDays = new Set(rows.map(row => String(row.dayOfWeek)));
const isSingle = rows.length === 1;
const first = rows[0];
return {
date: isSingle
? `${formatLongDate(first.date)} (${shortDayLabel(first.dayOfWeek)})`
: `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`,
course: courses.size === 1 ? `${first.courseNumber} курс` : `${courses.size} курса`,
week: weeks.size === 1 ? `${first.weekNumber} неделя` : `${weeks.size} недель`,
day: weekDays.size === 1 ? dayLabel(first.dayOfWeek) : `${rows.length} ячеек`
};
}
export function dayDialogSummaryParts(rows, activityTypes) {
if (rows.length <= 1) {
const activity = rows[0] ? activityById(rows[0].activityTypeId, activityTypes) : null;
return activity
? { kind: 'single', activity }
: { kind: 'empty' };
}
const common = commonActivityValue(rows);
const activity = common ? activityById(common, activityTypes) : null;
return activity
? { kind: 'multi', activity, count: rows.length }
: { kind: 'multi-empty', count: rows.length };
}
export function dayTooltipParts(row, activityTypes, scopes, assignments) {
const activity = activityById(row.activityTypeId, activityTypes);
// Код ячейки берётся с учётом запасного типа активности (реплика legacy:
// код ячейки — activityById(...) || activityTypes[0] || {}).
const cellActivity = activity || activityTypes[0] || {};
const code = activity?.code || cellActivity.code || row.activityCode || '-';
const assignment = timeAssignmentForDate(assignments, row.date);
const timeLabel = assignment
? ((scopes || []).find(scope => String(scope.id) === String(assignment.scopeId))?.name || 'ручная сетка')
: automaticTimeScopeLabel(row.date, scopes).replace('По правилу даты ', '');
return {
code,
longDate: formatLongDate(row.date),
shortDay: shortDayLabel(row.dayOfWeek),
week: row.weekNumber,
course: row.courseNumber,
activityName: activity?.name || 'Не задана',
timeLabel,
colorClass: activity ? activityColorClass(activity) : ''
};
}
function lowercaseFirst(value) {
const text = String(value || '');
return text ? text[0].toLowerCase() + text.slice(1) : text;
}