Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
768 lines
27 KiB
JavaScript
768 lines
27 KiB
JavaScript
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' });
|
||
}
|