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:
767
frontend/react/admin/schedule-view-service.js
Normal file
767
frontend/react/admin/schedule-view-service.js
Normal 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' });
|
||||
}
|
||||
Reference in New Issue
Block a user