Files
magistr/frontend/react/admin/schedule-view-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

768 lines
27 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';
// Срез «Просмотр расписаний» (этап 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' });
}