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
This commit is contained in:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,767 @@
import { apiClient } from '../shared/api-client.js';
// Срез «Просмотр расписаний» (этап 5, волна 5): чистая логика и контракты API,
// перенесённые из legacy admin/js/views/schedule-view.js без изменения поведения.
export const ROLE_DEPARTMENT = 'DEPARTMENT';
export const TARGET_GROUPS = 'groups';
export const TARGET_TEACHERS = 'teachers';
export const TARGET_CLASSROOMS = 'classrooms';
export const TARGET_DEPARTMENTS = 'departments';
export const PARITY_LABELS = Object.freeze({
BOTH: 'каждая',
ODD: 'нечётная',
EVEN: 'чётная'
});
export const DAY_SHORT_LABELS = Object.freeze({
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
});
export const DAY_FULL_LABELS = Object.freeze({
1: 'Понедельник',
2: 'Вторник',
3: 'Среда',
4: 'Четверг',
5: 'Пятница',
6: 'Суббота',
7: 'Воскресенье'
});
export const DAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
export const CELL_PARITY_LAYOUT = Object.freeze([
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
]);
export const MOBILE_QUERY = '(max-width: 760px)';
export const DEFAULT_SEMESTER_WEEKS = 22;
export const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание';
// --- Дата и время ---
export function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
export function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
export function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function isoDay(value) {
const day = parseIsoDate(value).getDay();
return day === 0 ? 7 : day;
}
export function datesBetween(startDate, endDate) {
const dates = [];
for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) {
dates.push(toIsoDate(date));
}
return dates;
}
export function weekStart(value) {
const date = parseIsoDate(value);
const day = date.getDay() || 7;
date.setDate(date.getDate() + 1 - day);
return toIsoDate(date);
}
export function twoWeekRange(value) {
const selected = parseIsoDate(value);
const start = addDays(selected, 1 - isoDay(value));
const end = addDays(start, 13);
return {
startDate: toIsoDate(start),
endDate: toIsoDate(end)
};
}
export function formatDate(value) {
if (!value) return '-';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
export function trimTime(value) {
return String(value || '').slice(0, 5);
}
export function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
// --- Семестры и периоды ---
export function semesterOptionLabel(semester) {
const type = String(semester?.semesterType || '').toLowerCase();
const typeLabel = type === 'autumn'
? 'Осенний семестр'
: (type === 'spring' ? 'Весенний семестр' : 'Семестр');
const academicYear = semester?.academicYearTitle || 'Учебный год';
const dates = semester?.startDate && semester?.endDate
? ` · ${formatDate(semester.startDate)} — ${formatDate(semester.endDate)}`
: '';
return `${academicYear} · ${typeLabel}${dates}`;
}
export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
}
export function scheduleRangeForSemester(selectedDate, semester) {
if (semester?.startDate && semester?.endDate) {
return {
startDate: semester.startDate,
endDate: semester.endDate
};
}
return twoWeekRange(selectedDate);
}
export function dateWithinSemester(date, semester) {
return Boolean(
date
&& semester?.startDate
&& semester?.endDate
&& date >= semester.startDate
&& date <= semester.endDate
);
}
export function semesterWeekCount(semester) {
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
export function semesterScheduleCaption(semester) {
const weeks = semesterWeekCount(semester);
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
return `Весь семестр · ${weekLabel}`;
}
export function periodCaption(range, semester = null) {
if (!range) return 'Учебное расписание';
const rangeLabel = `${formatDate(range.startDate)} — ${formatDate(range.endDate)}`;
return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel;
}
export function formatLessonWeeksText(startWeek, endWeek) {
const start = Number(startWeek);
const end = Number(endWeek);
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
return start === end
? `(${start} нед.)`
: `(с ${start} по ${end} нед.)`;
}
// Диапазон недель занятия: явные границы из матрицы либо расчёт по часам.
export function getLessonWeeksText(lesson) {
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
}
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
return '';
}
const totalHours = lesson.lessonTypeAcademicHours;
const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0;
// Каждое занятие - это 2 часа
const totalLessons = Math.ceil(totalHours / 2);
if (totalLessons <= 0) return '';
const parity = lesson.ruleParity || lesson.parity;
let startWeek;
let endWeek;
if (parity === 'EVEN' || parity === 'ODD') {
// Раз в две недели
startWeek = lesson.weekNumber - consumedBefore;
endWeek = startWeek + (totalLessons - 1) * 2;
} else {
// Каждую неделю
const steps = Math.floor(consumedBefore / 2);
startWeek = lesson.weekNumber - steps;
endWeek = startWeek + totalLessons - 1;
}
if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek;
return formatLessonWeeksText(startWeek, endWeek);
}
// --- Разделы расписания ---
export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) {
return target === TARGET_GROUPS && selectedGroupId
? new Set([String(selectedGroupId)])
: new Set();
}
export function splitTypeForTarget(target) {
if (target === TARGET_TEACHERS) return TARGET_TEACHERS;
if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS;
return TARGET_GROUPS;
}
export function buildSectionContext({ target, selectedGroupId, groups }) {
const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId);
const groupNamesById = new Map((groups || []).map(group => [String(group.id), group.name]));
return {
selectedGroupId,
selectedGroupName: selectedGroupId
? (groupNamesById.get(String(selectedGroupId)) || `Группа ${selectedGroupId}`)
: '',
allowedGroupIds,
groupNamesById
};
}
export function buildScheduleSections(lessons, splitType, context) {
const sections = new Map();
lessons.forEach(lesson => {
splitEntitiesForLesson(lesson, splitType, context).forEach(entity => {
if (!sections.has(entity.key)) {
sections.set(entity.key, {
key: entity.key,
title: entity.title,
lessons: []
});
}
sections.get(entity.key).lessons.push(lesson);
});
});
return Array.from(sections.values())
.map(section => ({
...section,
lessons: sortLessons(section.lessons)
}))
.sort((a, b) => naturalCompare(a.title, b.title));
}
function splitEntitiesForLesson(lesson, splitType, context) {
if (splitType === TARGET_TEACHERS) {
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
}
if (splitType === TARGET_CLASSROOMS) {
return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')];
}
if (context.selectedGroupId) {
return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')];
}
return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById);
}
function singleEntity(prefix, id, name, fallback) {
const title = name || fallback;
return {
id,
key: `${prefix}:${id ?? title}`,
title
};
}
function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) {
const safeIds = Array.isArray(ids) ? ids : [];
const safeNames = Array.isArray(names) ? names : [];
if (!safeIds.length && !safeNames.length) {
return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)];
}
const maxLength = Math.max(safeIds.length, safeNames.length);
const entities = [];
for (let index = 0; index < maxLength; index += 1) {
const id = safeIds[index] ?? null;
if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) {
continue;
}
const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`);
entities.push(singleEntity(prefix, id, name, fallback));
}
return entities;
}
export function sortLessons(lessons) {
return [...lessons].sort((a, b) =>
naturalCompare(a.date, b.date)
|| compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b))
|| naturalCompare(a.subjectName, b.subjectName)
|| naturalCompare(a.teacherName, b.teacherName)
|| naturalCompare(a.classroomName, b.classroomName)
);
}
function lessonSlotDescriptor(lesson) {
return {
key: lessonSlotKey(lesson),
orderNumber: lesson.timeSlotOrder,
startTime: lesson.startTime
};
}
// --- Слоты и сетки ---
export function lessonSlotKey(lesson) {
const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`;
return value ? String(value) : 'unknown';
}
export function dateSlotKey(date, slotKey) {
return `${date}:${slotKey}`;
}
export function collectSlots(lessons) {
const slots = new Map();
lessons.forEach(lesson => {
const key = lessonSlotKey(lesson);
const existing = slots.get(key);
const next = {
key,
id: lesson.timeSlotId,
orderNumber: lesson.timeSlotOrder,
startTime: lesson.startTime,
endTime: lesson.endTime,
orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара',
timeLabel: lesson.startTime || lesson.endTime
? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}`
: 'Время не указано'
};
if (!existing || existing.timeLabel === 'Время не указано') {
slots.set(key, next);
}
});
return Array.from(slots.values()).sort(compareSlots);
}
export function groupLessonsByDateSlot(lessons) {
const grouped = new Map();
lessons.forEach(lesson => {
if (!lesson.date) return;
const key = dateSlotKey(lesson.date, lessonSlotKey(lesson));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)
|| naturalCompare(a.teacherName, b.teacherName)
|| naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(',')));
});
return grouped;
}
export function buildCombinedWeekBlocks(startDate, endDate, lessons) {
const dates = datesBetween(startDate, endDate);
const weekGroups = new Map();
dates.forEach(date => {
const key = weekStart(date);
if (!weekGroups.has(key)) weekGroups.set(key, []);
weekGroups.get(key).push(date);
});
const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b));
const blocks = [];
for (let index = 0; index < weekStarts.length; index += 2) {
const firstWeek = weekGroups.get(weekStarts[index]) || [];
const secondWeek = weekGroups.get(weekStarts[index + 1]) || [];
const blockDates = [...firstWeek, ...secondWeek];
const firstParity = weekParity(firstWeek, lessons) || 'ODD';
const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD');
const datesWithFallback = [
...firstWeek.map(date => ({ date, fallbackParity: firstParity })),
...secondWeek.map(date => ({ date, fallbackParity: secondParity }))
];
blocks.push({
startDate: blockDates[0],
endDate: blockDates[blockDates.length - 1],
datesByDay: buildDatesByDay(datesWithFallback, lessons),
label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}`
});
}
return blocks;
}
function weekParity(dates, lessons) {
for (const date of dates) {
const parity = parityForDate(date, lessons);
if (parity) return parity;
}
return null;
}
export function buildDatesByDay(datesWithFallback, lessons) {
const datesByDay = {};
datesWithFallback.forEach(({ date, fallbackParity }) => {
const day = isoDay(date);
const parity = parityForDate(date, lessons) || fallbackParity;
if (!datesByDay[day]) datesByDay[day] = {};
datesByDay[day][parity] = date;
});
return datesByDay;
}
export function parityForDate(date, lessons) {
const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity));
if (lesson?.parity) return lesson.parity;
const withWeek = lessons.find(item => item.date === date && item.weekNumber != null);
if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD';
return null;
}
export function combinedWeekCaption() {
return 'Нечётная и чётная недели';
}
export function mobileDayCaption() {
return 'Нечётная / Чётная';
}
// --- Матрица семестра ---
export function buildSemesterLessonMatrix(lessons) {
const weekRanges = new Map();
lessons.forEach(lesson => {
const signature = lessonContentSignature(lesson);
const weekNumber = Number(lesson.weekNumber);
if (!Number.isFinite(weekNumber)) return;
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
range.startWeek = Math.min(range.startWeek, weekNumber);
range.endWeek = Math.max(range.endWeek, weekNumber);
weekRanges.set(signature, range);
});
const matrix = new Map();
lessons.forEach(lesson => {
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
? lesson.parity
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
const signature = lessonContentSignature(lesson);
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
if (!matrix.has(key)) matrix.set(key, new Map());
if (!matrix.get(key).has(signature)) {
const range = weekRanges.get(signature);
matrix.get(key).set(signature, {
...lesson,
displayStartWeek: range?.startWeek,
displayEndWeek: range?.endWeek
});
}
});
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
key,
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
]));
}
export function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity,
label,
state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot)
}));
}
export function slotStatesForSemester(day, slot, lessonMatrix) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
return {
parity,
label,
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
};
});
}
export function scheduleSlotState(date, slot, lessonsByDateSlot) {
if (!date) {
return { kind: 'empty', lessons: [] };
}
const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || [];
if (lessons.length) {
return { kind: 'busy', lessons };
}
return { kind: 'free', lessons: [] };
}
export function slotStatesEqual(first, second) {
if (first.kind !== second.kind) return false;
if (first.kind !== 'busy') return true;
return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
}
function lessonSetSignature(lessons) {
return lessons
.map(lessonContentSignature)
.sort((a, b) => naturalCompare(a, b))
.join('|');
}
export function lessonContentSignature(lesson) {
return [
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
lesson.subjectId || lesson.subjectName || '',
lesson.teacherId || lesson.teacherName || '',
lesson.classroomId || lesson.classroomName || '',
lesson.lessonTypeId || lesson.lessonTypeName || '',
lesson.lessonFormat || '',
lesson.scheduleOverrideId || '',
lesson.overrideAction || '',
lesson.originalLessonDate || '',
valueListSignature(lesson.groupIds, lesson.groupNames),
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
].join(':');
}
function valueListSignature(primary = [], fallback = []) {
const primaryList = Array.isArray(primary) ? primary : [];
const fallbackList = Array.isArray(fallback) ? fallback : [];
const source = primaryList.length ? primaryList : fallbackList;
return [...source]
.map(value => String(value))
.sort((a, b) => naturalCompare(a, b))
.join(',');
}
// --- Подписи ---
export function lessonCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} занятий`;
if (count % 10 === 1) return `${count} занятие`;
if ([2, 3, 4].includes(count % 10)) return `${count} занятия`;
return `${count} занятий`;
}
export function sectionCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} расписаний`;
if (count % 10 === 1) return `${count} расписание`;
if ([2, 3, 4].includes(count % 10)) return `${count} расписания`;
return `${count} расписаний`;
}
// --- Справочники ---
export function roomLabel(room) {
return [room?.name, room?.building, room?.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · ');
}
export function groupOptionLabel(group) {
return group?.name || '';
}
export function teacherOptionLabel(teacher) {
return teacher?.fullName || teacher?.username || '';
}
export function mergeDictionaryItems(dictionaries, key, items) {
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
(items || []).forEach(item => merged.set(String(item.id), item));
dictionaries[key] = [...merged.values()];
}
// --- Контракты API ---
export async function fetchScheduleViewDictionaries(client = apiClient) {
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
client.get('/api/departments', { errorMessage: 'Ошибка загрузки кафедр' }),
client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' }),
client.get('/api/lesson-types', { errorMessage: 'Ошибка загрузки типов занятий' }),
client.get('/api/schedule/semesters', { errorMessage: 'Ошибка загрузки семестров' })
]);
return {
groups: [],
teachers: [],
classrooms: [],
departments: departments || [],
subjects: subjects || [],
lessonTypes: lessonTypes || [],
semesters: semesters || []
};
}
export function scheduleSearchEndpoint(filters) {
const params = new URLSearchParams({
startDate: filters.startDate,
endDate: filters.endDate
});
applyTargetFilter(params, filters);
addOptional(params, 'subjectId', filters.subjectId);
addOptional(params, 'lessonTypeId', filters.lessonTypeId);
addOptional(params, 'parity', filters.parity);
return `/api/schedule/search?${params}`;
}
// Backend отвергает поиск сразу по всем активным группам («Уточните группу
// или кафедру»), поэтому запрос без выбранной цели заведомо вернёт 400.
// Используется, чтобы не показывать ошибку там, где достаточно подсказки.
export function hasScheduleSearchTarget(filters) {
if (filters.role === ROLE_DEPARTMENT) {
return Boolean(filters.userDepartmentId);
}
if (filters.target === TARGET_GROUPS) {
return Boolean(filters.groupId);
}
if (filters.target === TARGET_TEACHERS) {
return Boolean(filters.teacherId);
}
if (filters.target === TARGET_CLASSROOMS) {
return Boolean(filters.classroomId);
}
if (filters.target === TARGET_DEPARTMENTS) {
return Boolean(filters.departmentId);
}
return false;
}
function applyTargetFilter(params, filters) {
if (filters.role === ROLE_DEPARTMENT) {
params.set('departmentId', filters.userDepartmentId);
return;
}
if (filters.target === TARGET_GROUPS) {
addOptional(params, 'groupId', filters.groupId);
return;
}
if (filters.target === TARGET_TEACHERS) {
addOptional(params, 'teacherId', filters.teacherId);
return;
}
if (filters.target === TARGET_CLASSROOMS) {
addOptional(params, 'classroomId', filters.classroomId);
return;
}
if (filters.target === TARGET_DEPARTMENTS) {
addOptional(params, 'departmentId', filters.departmentId);
}
}
function addOptional(params, key, value) {
if (value) params.set(key, value);
}
export async function fetchScheduleLessons(filters, client = apiClient) {
return client.get(scheduleSearchEndpoint(filters), { errorMessage: 'Ошибка загрузки расписания' });
}
export function scheduleOverridesEndpoint(range) {
return `/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`;
}
export async function fetchScheduleOverrides(range, client = apiClient) {
return client.get(scheduleOverridesEndpoint(range), { errorMessage: 'Ошибка загрузки разовых изменений' });
}
export function groupsOptionsEndpoint(query, selectedId, departmentId = '') {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
if (departmentId) params.set('departmentId', departmentId);
return `/api/groups/options?${params}`;
}
export function teachersOptionsEndpoint(query, selectedId) {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
export function classroomsOptionsEndpoint(query, selectedId) {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return `/api/classrooms/options?${params}`;
}
export async function fetchGroupOptions(query, selectedId, departmentId, client = apiClient) {
return client.get(groupsOptionsEndpoint(query, selectedId, departmentId), { errorMessage: 'Ошибка поиска групп' });
}
export async function fetchTeacherOptions(query, selectedId, client = apiClient) {
return client.get(teachersOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска преподавателей' });
}
export async function fetchClassroomOptions(query, selectedId, client = apiClient) {
return client.get(classroomsOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска аудиторий' });
}
// --- Состояние фильтров в URL ---
export function initialScheduleViewState(values = {}) {
return {
target: values.scheduleTarget || TARGET_GROUPS,
entity: values.scheduleEntity || '',
date: values.scheduleDate || '',
yearId: values.scheduleYear || '',
semesterId: values.scheduleSemester || '',
subjectId: values.scheduleSubject || '',
lessonTypeId: values.scheduleLessonType || '',
parity: values.scheduleParity || ''
};
}
export function entityValueForTarget(target, entityIds = {}) {
if (target === TARGET_GROUPS) return entityIds.groupId || '';
if (target === TARGET_TEACHERS) return entityIds.teacherId || '';
if (target === TARGET_CLASSROOMS) return entityIds.classroomId || '';
if (target === TARGET_DEPARTMENTS) return entityIds.departmentId || '';
return '';
}
export function scheduleViewUrlState(filters) {
return {
scheduleTarget: filters.role === ROLE_DEPARTMENT ? '' : filters.target,
scheduleEntity: filters.entityValue || '',
scheduleDate: filters.date || '',
scheduleYear: filters.yearId || '',
scheduleSemester: filters.semesterId || '',
scheduleSubject: filters.subjectId || '',
scheduleLessonType: filters.lessonTypeId || '',
scheduleParity: filters.parity || ''
};
}
// --- Служебные сравнения ---
function compareSlots(a, b) {
const first = Number(a.orderNumber || a.key);
const second = Number(b.orderNumber || b.key);
if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
return first - second;
}
return naturalCompare(a.startTime, b.startTime)
|| naturalCompare(a.key, b.key);
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}