Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
860 lines
34 KiB
JavaScript
860 lines
34 KiB
JavaScript
import { apiClient } from '../shared/api-client.js';
|
||
import { formatLocalDate } from '../../admin/js/utils.js';
|
||
import { parseIsoDate, toIsoDate, trimTime } from './schedule-view-service.js';
|
||
|
||
// Волна 6 (этап 5): чистая логика и контракты API конструктора правил
|
||
// расписания, перенесённые из legacy admin/js/views/schedule.js без изменения
|
||
// поведения.
|
||
|
||
export const DAY_OPTIONS = Object.freeze([
|
||
Object.freeze({ value: 1, label: 'Понедельник' }),
|
||
Object.freeze({ value: 2, label: 'Вторник' }),
|
||
Object.freeze({ value: 3, label: 'Среда' }),
|
||
Object.freeze({ value: 4, label: 'Четверг' }),
|
||
Object.freeze({ value: 5, label: 'Пятница' }),
|
||
Object.freeze({ value: 6, label: 'Суббота' }),
|
||
Object.freeze({ value: 7, label: 'Воскресенье' })
|
||
]);
|
||
|
||
export const PARITY_LABELS = Object.freeze({
|
||
BOTH: 'каждую неделю',
|
||
ODD: 'нечётная',
|
||
EVEN: 'чётная'
|
||
});
|
||
|
||
export const CELL_PARITY_LAYOUT = Object.freeze([
|
||
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
|
||
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
|
||
]);
|
||
|
||
export const SEMESTER_LABELS = Object.freeze({
|
||
autumn: 'осенний',
|
||
spring: 'весенний'
|
||
});
|
||
|
||
export const LESSON_FORMAT_OPTIONS = Object.freeze([
|
||
Object.freeze({ value: 'Очно', label: 'Очно' }),
|
||
Object.freeze({ value: 'Онлайн', label: 'Онлайн' })
|
||
]);
|
||
|
||
export const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||
export const ACADEMIC_HOURS_PER_SLOT = 2;
|
||
|
||
const VISUAL_MENU_GAP = 8;
|
||
const VISUAL_MENU_VIEWPORT_PADDING = 8;
|
||
|
||
export const LESSON_TYPE_LIMITS = Object.freeze([
|
||
Object.freeze({
|
||
key: 'lecture',
|
||
label: 'Лекции',
|
||
hoursField: 'lectureAcademicHours',
|
||
startWeekField: 'lectureStartWeek'
|
||
}),
|
||
Object.freeze({
|
||
key: 'laboratory',
|
||
label: 'Лабораторные',
|
||
hoursField: 'laboratoryAcademicHours',
|
||
startWeekField: 'laboratoryStartWeek'
|
||
}),
|
||
Object.freeze({
|
||
key: 'practice',
|
||
label: 'Практики',
|
||
hoursField: 'practiceAcademicHours',
|
||
startWeekField: 'practiceStartWeek'
|
||
})
|
||
]);
|
||
|
||
// --- Позиционирование контекстного меню визуального расписания ---
|
||
|
||
export function calculateVisualContextMenuPosition({
|
||
anchorRect,
|
||
menuWidth,
|
||
menuHeight,
|
||
viewportWidth,
|
||
viewportHeight
|
||
}) {
|
||
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
|
||
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
|
||
let left = anchorRect.right + VISUAL_MENU_GAP;
|
||
let top = anchorRect.top;
|
||
|
||
if (left > maxLeft) {
|
||
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
|
||
}
|
||
if (top > maxTop) {
|
||
top = anchorRect.bottom - menuHeight;
|
||
}
|
||
|
||
return {
|
||
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
|
||
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
|
||
};
|
||
}
|
||
|
||
// --- Слоты формы ---
|
||
|
||
export function scheduleSlotActionLabel(slotCount) {
|
||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||
}
|
||
|
||
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
|
||
return !programmatic && !(type === 'input' && fromAsyncCombobox);
|
||
}
|
||
|
||
// --- Версии расписания ---
|
||
|
||
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
|
||
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||
return available.find(version => String(version.id) === String(requestedVersionId))
|
||
|| available.find(version => version.status === 'PUBLISHED'
|
||
&& String(version.semesterId) === String(semesterId))
|
||
|| available.find(version => version.status === 'PUBLISHED')
|
||
|| available.find(version => String(version.semesterId) === String(semesterId))
|
||
|| available[0]
|
||
|| null;
|
||
}
|
||
|
||
export function scheduleVersionOptionGroups(versions) {
|
||
const published = versions.filter(version => version.status === 'PUBLISHED');
|
||
const drafts = versions.filter(version => version.status === 'DRAFT');
|
||
return [
|
||
{ label: 'Опубликованные расписания', items: published },
|
||
{ label: 'Черновики', items: drafts }
|
||
].filter(group => group.items.length);
|
||
}
|
||
|
||
export function versionContextTexts(version) {
|
||
const isDraft = version?.status === 'DRAFT';
|
||
if (!version) {
|
||
return {
|
||
status: 'PUBLISHED',
|
||
eyebrow: 'Основной режим',
|
||
title: 'Опубликованное расписание',
|
||
note: 'Расписание для редактирования не найдено.'
|
||
};
|
||
}
|
||
return {
|
||
status: isDraft ? 'DRAFT' : 'PUBLISHED',
|
||
eyebrow: isDraft ? 'Безопасная рабочая копия' : 'Основной режим',
|
||
title: isDraft ? 'Редактирование черновика' : 'Опубликованное расписание',
|
||
note: isDraft
|
||
? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.`
|
||
: `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`
|
||
};
|
||
}
|
||
|
||
// --- Подписи семестров и правил ---
|
||
|
||
function normalizeSemesterType(type) {
|
||
return String(type || '').toLowerCase();
|
||
}
|
||
|
||
export function semesterLabel(semester) {
|
||
const type = normalizeSemesterType(semester.semesterType);
|
||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||
}
|
||
|
||
export function shortSemesterLabel(semester) {
|
||
const type = normalizeSemesterType(semester.semesterType);
|
||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
|
||
}
|
||
|
||
export function formatSemester(rule) {
|
||
const type = normalizeSemesterType(rule.semesterType);
|
||
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
||
}
|
||
|
||
export function semesterTextById(semesterId, semesters) {
|
||
const semester = (semesters || []).find(item => String(item.id) === String(semesterId));
|
||
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
|
||
}
|
||
|
||
export function typeLoadText(rule) {
|
||
return LESSON_TYPE_LIMITS
|
||
.map(config => {
|
||
const hours = Number(rule?.[config.hoursField] || 0);
|
||
const startWeek = Number(rule?.[config.startWeekField] || 1);
|
||
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
|
||
})
|
||
.filter(Boolean)
|
||
.join('; ');
|
||
}
|
||
|
||
export function groupNameById(id, groups) {
|
||
return (groups || []).find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
|
||
}
|
||
|
||
export function ruleDraftTitle(payload, subjects) {
|
||
const subject = (subjects || []).find(item => String(item.id) === String(payload.subjectId));
|
||
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
|
||
}
|
||
|
||
export function ruleTitle(rule, subjects) {
|
||
return rule?.subjectName || ruleDraftTitle(rule || {}, subjects);
|
||
}
|
||
|
||
export function ruleDraftMeta(payload, { groups, semesters }) {
|
||
const groupNames = (payload.groupIds || []).map(id => groupNameById(id, groups)).join(', ') || 'группы не выбраны';
|
||
return [semesterTextById(payload.semesterId, semesters), groupNames, typeLoadText(payload)]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
}
|
||
|
||
export function ruleMeta(rule, { groups, semesters }) {
|
||
const groupNames = (rule.groupNames || []).join(', ')
|
||
|| (rule.groupIds || []).map(id => groupNameById(id, groups)).join(', ')
|
||
|| 'группы не выбраны';
|
||
return [formatSemester(rule), groupNames, typeLoadText(rule)]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
}
|
||
|
||
// --- Типы занятий ---
|
||
|
||
export function lessonTypeKey(lessonType) {
|
||
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
|
||
if (name.includes('лекц')) return 'lecture';
|
||
if (name.includes('лаб')) return 'laboratory';
|
||
if (name.includes('практ')) return 'practice';
|
||
return null;
|
||
}
|
||
|
||
export function lessonTypeKeyById(lessonTypeId, lessonTypes) {
|
||
const lessonType = (lessonTypes || []).find(item => String(item.id) === String(lessonTypeId));
|
||
return lessonType ? lessonTypeKey(lessonType) : null;
|
||
}
|
||
|
||
function lessonTypeRank(lessonType) {
|
||
const key = lessonTypeKey(lessonType);
|
||
if (key === 'lecture') return 0;
|
||
if (key === 'laboratory') return 1;
|
||
if (key === 'practice') return 2;
|
||
return 3;
|
||
}
|
||
|
||
export function toLessonTypeOption(lessonType) {
|
||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||
}
|
||
|
||
export function orderedLessonTypes(lessonTypes) {
|
||
return [...(lessonTypes || [])].sort((first, second) => (
|
||
lessonTypeRank(first) - lessonTypeRank(second)
|
||
|| naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label)
|
||
));
|
||
}
|
||
|
||
export function isLaboratorySlot(slot, lessonTypes) {
|
||
return lessonTypeKeyById(slot.lessonTypeId, lessonTypes) === 'laboratory';
|
||
}
|
||
|
||
export function toTimeSlotOption(slot) {
|
||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||
}
|
||
|
||
// --- Подгруппы ---
|
||
|
||
export function slotSubgroupIds(slot) {
|
||
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
|
||
return slot.subgroupIds.map(Number).filter(Boolean);
|
||
}
|
||
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
|
||
}
|
||
|
||
export function slotSubgroupNames(slot) {
|
||
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
|
||
return slot.subgroupNames.filter(Boolean);
|
||
}
|
||
return slot.subgroupName ? [slot.subgroupName] : [];
|
||
}
|
||
|
||
export function subgroupLabel(subgroup) {
|
||
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
|
||
}
|
||
|
||
export function matchingSubgroupsForGroups(subgroups, selectedGroupIds) {
|
||
const selectedGroups = new Set((selectedGroupIds || []).map(String));
|
||
return (subgroups || [])
|
||
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||
.sort((first, second) => subgroupLabel(first).localeCompare(subgroupLabel(second), 'ru-RU'));
|
||
}
|
||
|
||
export function slotSubgroupText(slot, subgroups) {
|
||
const selectedIds = slotSubgroupIds(slot);
|
||
if (!selectedIds.length) return 'Все выбранные группы';
|
||
if (selectedIds.length === 1) {
|
||
const subgroup = (subgroups || []).find(item => String(item.id) === String(selectedIds[0]));
|
||
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
|
||
}
|
||
return `Выбрано подгрупп: ${selectedIds.length}`;
|
||
}
|
||
|
||
// --- Payload правил ---
|
||
|
||
export function ruleSlotToPayload(slot) {
|
||
return {
|
||
id: slot.id || null,
|
||
dayOfWeek: Number(slot.dayOfWeek),
|
||
parity: slot.parity || 'BOTH',
|
||
timeSlotId: Number(slot.timeSlotId),
|
||
subgroupId: slot.subgroupId || null,
|
||
subgroupIds: slotSubgroupIds(slot),
|
||
teacherId: Number(slot.teacherId),
|
||
classroomId: Number(slot.classroomId),
|
||
lessonTypeId: Number(slot.lessonTypeId),
|
||
lessonFormat: slot.lessonFormat || 'Очно'
|
||
};
|
||
}
|
||
|
||
export function buildRulePayloadFromRule(rule, slots, fallbackVersionId = '') {
|
||
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
|
||
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
|
||
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
|
||
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
|
||
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
|
||
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
|
||
return {
|
||
id: Number(rule.id),
|
||
subjectId: Number(rule.subjectId),
|
||
semesterId: Number(rule.semesterId),
|
||
scheduleVersionId: Number(rule.scheduleVersionId || fallbackVersionId),
|
||
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
|
||
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
|
||
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
|
||
lectureStartWeek: Number(rule.lectureStartWeek || 1),
|
||
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
|
||
practiceStartWeek: Number(rule.practiceStartWeek || 1),
|
||
groupIds,
|
||
slots
|
||
};
|
||
}
|
||
|
||
export function validateTypeLimits(typeLimits, slots, lessonTypes) {
|
||
const selectedTypeKeys = new Set(
|
||
(slots || []).map(slot => lessonTypeKeyById(slot.lessonTypeId, lessonTypes)).filter(Boolean)
|
||
);
|
||
let hasHours = false;
|
||
for (const config of LESSON_TYPE_LIMITS) {
|
||
const hours = typeLimits[config.hoursField];
|
||
const startWeek = typeLimits[config.startWeekField];
|
||
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
|
||
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
|
||
if (hours > 0) hasHours = true;
|
||
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
|
||
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
|
||
}
|
||
if (hours === 0 && selectedTypeKeys.has(config.key)) {
|
||
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
|
||
}
|
||
}
|
||
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
|
||
}
|
||
|
||
export function validateSlotSubgroup(slot, index, { lessonTypes, subgroups, selectedGroupIds }) {
|
||
const typeKey = lessonTypeKeyById(slot.lessonTypeId, lessonTypes);
|
||
const subgroupIds = slotSubgroupIds(slot);
|
||
if (subgroupIds.length && typeKey !== 'laboratory') {
|
||
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
||
}
|
||
if (!subgroupIds.length) return;
|
||
|
||
const selectedGroups = new Set((selectedGroupIds || []).map(String));
|
||
const subgroupGroupIds = new Set();
|
||
for (const subgroupId of subgroupIds) {
|
||
const selectedSubgroup = (subgroups || []).find(item => String(item.id) === String(subgroupId));
|
||
if (!selectedSubgroup) {
|
||
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
|
||
}
|
||
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
|
||
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
|
||
}
|
||
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
|
||
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
|
||
}
|
||
subgroupGroupIds.add(String(selectedSubgroup.groupId));
|
||
}
|
||
}
|
||
|
||
// Слоты формы → payload с валидацией обязательных полей (как readRuleSlots(true)).
|
||
export function formSlotsToPayloads(slots, { lessonTypes, subgroups, selectedGroupIds }) {
|
||
return (slots || []).map((slot, index) => {
|
||
if (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|
||
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat) {
|
||
throw new Error(`Заполните слот ${index + 1}`);
|
||
}
|
||
const payload = ruleSlotToPayload(slot);
|
||
validateSlotSubgroup(payload, index, { lessonTypes, subgroups, selectedGroupIds });
|
||
return payload;
|
||
});
|
||
}
|
||
|
||
export function buildRulePayloadFromForm({
|
||
ruleId,
|
||
subjectId,
|
||
semesterId,
|
||
versionId,
|
||
typeLimits,
|
||
groupIds,
|
||
slots,
|
||
lessonTypes,
|
||
subgroups
|
||
}) {
|
||
if (!subjectId) throw new Error('Выберите дисциплину');
|
||
if (!versionId) throw new Error('Выберите версию расписания');
|
||
if (!semesterId) throw new Error('Выберите семестр');
|
||
validateTypeLimits(typeLimits, slots, lessonTypes);
|
||
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
|
||
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
|
||
return {
|
||
id: ruleId ? Number(ruleId) : null,
|
||
subjectId: Number(subjectId),
|
||
semesterId: Number(semesterId),
|
||
scheduleVersionId: Number(versionId),
|
||
...typeLimits,
|
||
groupIds,
|
||
slots
|
||
};
|
||
}
|
||
|
||
// --- Конфликты правил ---
|
||
|
||
export function isScheduleRuleConflictError(error) {
|
||
return error?.status === 409 && Boolean(error?.data?.conflictRule);
|
||
}
|
||
|
||
export function conflictFieldClass(field, fields) {
|
||
return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
|
||
}
|
||
|
||
export function conflictFieldLabel(field) {
|
||
if (field === 'teacher') return 'Преподаватель';
|
||
if (field === 'classroom') return 'Аудитория';
|
||
if (field === 'group') return 'Группа';
|
||
return field;
|
||
}
|
||
|
||
export function conflictSlotCurrentText(slot) {
|
||
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
|
||
return [
|
||
slot.dayName,
|
||
slot.timeSlotLabel,
|
||
parity,
|
||
slot.teacherName,
|
||
slot.classroomName
|
||
].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
export function conflictReasonLabels(fields, reasons) {
|
||
const labels = Array.isArray(reasons) && reasons.length
|
||
? reasons
|
||
: (fields || []).map(conflictFieldLabel).filter(Boolean);
|
||
return labels;
|
||
}
|
||
|
||
// --- Подсказки пожеланий преподавателя ---
|
||
|
||
export function preferenceHintLabel(type) {
|
||
return {
|
||
HARD_UNAVAILABLE: 'Строгая недоступность',
|
||
SOFT_PREFERRED: 'Предпочтительно',
|
||
SOFT_UNWANTED: 'Нежелательно',
|
||
CONSECUTIVE: 'Пары подряд',
|
||
NO_GAPS: 'Без окон'
|
||
}[type] || type;
|
||
}
|
||
|
||
export function preferenceHintPills({ semesterId, teacherId, dayOfWeek, timeSlotId, preferences }) {
|
||
if (!semesterId || !teacherId) return null;
|
||
const teacherItems = (preferences || []).filter(item =>
|
||
Number(item.semesterId) === Number(semesterId) && Number(item.teacherId) === Number(teacherId)
|
||
);
|
||
const exact = teacherItems.filter(item =>
|
||
Number(item.dayOfWeek) === Number(dayOfWeek) && Number(item.timeSlotId) === Number(timeSlotId)
|
||
);
|
||
const dateCount = teacherItems.filter(item => item.preferenceType === 'HARD_UNAVAILABLE' && item.preferenceDate).length;
|
||
const flags = teacherItems.filter(item => ['CONSECUTIVE', 'NO_GAPS'].includes(item.preferenceType));
|
||
return [
|
||
...exact.map(item => ({
|
||
text: preferenceHintLabel(item.preferenceType),
|
||
kind: item.preferenceType === 'HARD_UNAVAILABLE' ? 'hard'
|
||
: item.preferenceType === 'SOFT_PREFERRED' ? 'preferred' : 'unwanted'
|
||
})),
|
||
...(dateCount ? [{ text: `Недоступных дат: ${dateCount}`, kind: 'dates' }] : []),
|
||
...flags.map(item => ({ text: preferenceHintLabel(item.preferenceType), kind: 'compact' }))
|
||
];
|
||
}
|
||
|
||
// --- Визуальное расписание ---
|
||
|
||
export function visualCellKey(groupId, dayOfWeek, slotKey) {
|
||
return `${groupId}:${dayOfWeek}:${slotKey}`;
|
||
}
|
||
|
||
export function visualSlotKey(slot) {
|
||
return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
|
||
}
|
||
|
||
export function visualRuleSlotKey(slot, index) {
|
||
return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
|
||
}
|
||
|
||
export function activeWeekKey(ruleId, groupId, slotKey) {
|
||
return `${ruleId}:${groupId}:${slotKey}`;
|
||
}
|
||
|
||
export function slotAppliesToWeek(slot, week) {
|
||
if (!slot.parity || slot.parity === 'BOTH') return true;
|
||
const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
|
||
return slot.parity === weekParity;
|
||
}
|
||
|
||
export function maxWeeksForRule(rule, semesters) {
|
||
const semester = (semesters || []).find(item => String(item.id) === String(rule.semesterId));
|
||
if (semester?.startDate && semester?.endDate) {
|
||
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));
|
||
}
|
||
return 18;
|
||
}
|
||
|
||
export function compactWeekRanges(weeks, maxWeeks, parity) {
|
||
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
||
if (!uniqueWeeks.length) return '';
|
||
const start = uniqueWeeks[0];
|
||
const end = uniqueWeeks[uniqueWeeks.length - 1];
|
||
const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
|
||
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||
if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
|
||
if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
|
||
return weekRangeText(start, end);
|
||
}
|
||
|
||
function weekRangeText(start, end) {
|
||
return start === end
|
||
? `(${start} нед.)`
|
||
: `(с ${start} по ${end} нед.)`;
|
||
}
|
||
|
||
function firstApplicableSemesterWeek(parity) {
|
||
return parity === 'EVEN' ? 2 : 1;
|
||
}
|
||
|
||
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||
const totalWeeks = Number(maxWeeks || 0);
|
||
if (!totalWeeks) return 0;
|
||
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||
return totalWeeks;
|
||
}
|
||
|
||
function compareSlotItems(first, second) {
|
||
return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
|
||
|| compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
|
||
|| Number(first.index || 0) - Number(second.index || 0);
|
||
}
|
||
|
||
function slotDescriptor(slot) {
|
||
return {
|
||
key: visualSlotKey(slot),
|
||
orderNumber: slot.timeSlotOrder,
|
||
timeLabel: slot.timeSlotLabel
|
||
};
|
||
}
|
||
|
||
export function compareVisualSlots(first, second) {
|
||
const firstOrder = Number(first.orderNumber || first.key);
|
||
const secondOrder = Number(second.orderNumber || second.key);
|
||
if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
|
||
return firstOrder - secondOrder;
|
||
}
|
||
return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
|
||
}
|
||
|
||
export function buildActiveWeeksByRuleSlot(visualRules, { lessonTypes, semesters }) {
|
||
const activeWeeks = new Map();
|
||
(visualRules || []).forEach(rule => {
|
||
const maxWeeks = maxWeeksForRule(rule, semesters);
|
||
(rule.groupIds || []).forEach(groupId => {
|
||
LESSON_TYPE_LIMITS.forEach(config => {
|
||
const totalHours = Number(rule[config.hoursField] || 0);
|
||
if (totalHours <= 0) return;
|
||
const categorySlots = (rule.slots || [])
|
||
.map((slot, index) => ({ slot, index }))
|
||
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId, lessonTypes) === config.key)
|
||
.sort(compareSlotItems);
|
||
if (!categorySlots.length) return;
|
||
|
||
let consumedHours = 0;
|
||
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
|
||
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
|
||
for (const item of categorySlots) {
|
||
if (consumedHours >= totalHours) break;
|
||
if (!slotAppliesToWeek(item.slot, week)) continue;
|
||
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
|
||
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
|
||
activeWeeks.get(key).push(week);
|
||
consumedHours += ACADEMIC_HOURS_PER_SLOT;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
return activeWeeks;
|
||
}
|
||
|
||
export function buildVisualLessonsByCell(allowedGroupIds, visualRules, { lessonTypes, semesters, groups }) {
|
||
const grouped = new Map();
|
||
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules, { lessonTypes, semesters });
|
||
(visualRules || []).forEach(rule => {
|
||
(rule.slots || []).forEach((slot, index) => {
|
||
const slotKey = visualRuleSlotKey(slot, index);
|
||
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
||
if (!allowedGroupIds.has(String(groupId))) return;
|
||
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
|
||
if (!weeks.length) return;
|
||
const lesson = {
|
||
id: `${rule.id}:${slotKey}:${groupId}`,
|
||
ruleId: rule.id,
|
||
slotId: slot.id || '',
|
||
slotIndex: index,
|
||
dayOfWeek: slot.dayOfWeek,
|
||
timeSlotId: slot.timeSlotId,
|
||
parity: slot.parity || 'BOTH',
|
||
subjectName: rule.subjectName || 'Без дисциплины',
|
||
teacherName: slot.teacherName || '',
|
||
classroomName: slot.classroomName || '',
|
||
lessonTypeName: slot.lessonTypeName || '',
|
||
lessonFormat: slot.lessonFormat || '',
|
||
subgroupNames: slotSubgroupNames(slot),
|
||
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule, semesters), slot.parity),
|
||
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId, groups)
|
||
};
|
||
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
||
if (!grouped.has(key)) grouped.set(key, []);
|
||
grouped.get(key).push(lesson);
|
||
});
|
||
});
|
||
});
|
||
grouped.forEach(items => items.sort(compareVisualLessons));
|
||
return grouped;
|
||
}
|
||
|
||
export function buildVisualRows(columns, slots, lessonsByCell) {
|
||
const rows = [];
|
||
DAY_OPTIONS.forEach(day => {
|
||
const dayRows = (slots || [])
|
||
.map(slot => ({
|
||
day,
|
||
slot,
|
||
cells: columns.map(group => ({
|
||
group,
|
||
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
|
||
}))
|
||
}))
|
||
.filter(row => row.cells.some(cell => cell.lessons.length));
|
||
|
||
dayRows.forEach((row, index) => {
|
||
rows.push({
|
||
...row,
|
||
dayRowspan: index === 0 ? dayRows.length : 0
|
||
});
|
||
});
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
export function collectVisualGroups(visualRules, groups) {
|
||
const byId = new Map();
|
||
(visualRules || []).forEach(rule => {
|
||
(rule.groupIds || []).forEach((id, index) => {
|
||
if (!byId.has(String(id))) {
|
||
byId.set(String(id), {
|
||
id,
|
||
name: (rule.groupNames || [])[index] || groupNameById(id, groups)
|
||
});
|
||
}
|
||
});
|
||
});
|
||
return Array.from(byId.values()).sort((first, second) => naturalCompare(first.name, second.name));
|
||
}
|
||
|
||
export function collectVisualSlotsFromRules(visualRules) {
|
||
const byKey = new Map();
|
||
(visualRules || []).flatMap(rule => rule.slots || []).forEach(slot => {
|
||
const key = visualSlotKey(slot);
|
||
if (!byKey.has(key)) {
|
||
byKey.set(key, {
|
||
key,
|
||
orderNumber: slot.timeSlotOrder,
|
||
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
|
||
timeLabel: slot.timeSlotLabel || 'Время не указано'
|
||
});
|
||
}
|
||
});
|
||
return Array.from(byKey.values()).sort(compareVisualSlots);
|
||
}
|
||
|
||
export function visualSlotFromTimeSlot(slot) {
|
||
return {
|
||
key: String(slot.id),
|
||
orderNumber: slot.orderNumber,
|
||
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
|
||
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
|
||
};
|
||
}
|
||
|
||
export function compareVisualLessons(first, second) {
|
||
return naturalCompare(first.subjectName, second.subjectName)
|
||
|| naturalCompare(first.weekText, second.weekText)
|
||
|| naturalCompare(first.lessonTypeName, second.lessonTypeName)
|
||
|| naturalCompare(first.teacherName, second.teacherName);
|
||
}
|
||
|
||
export function visualLessonSignature(lessons) {
|
||
return (lessons || [])
|
||
.map(lesson => [
|
||
lesson.id,
|
||
lesson.subjectName,
|
||
lesson.weekText,
|
||
lesson.lessonTypeName,
|
||
lesson.teacherName,
|
||
lesson.classroomName,
|
||
lesson.subgroupNames.join(',')
|
||
].join(':'))
|
||
.sort((first, second) => naturalCompare(first, second))
|
||
.join('|');
|
||
}
|
||
|
||
export function uniqueVisualLessons(lessons) {
|
||
const unique = new Map();
|
||
(lessons || []).forEach(lesson => {
|
||
unique.set(visualLessonSignature([lesson]), lesson);
|
||
});
|
||
return Array.from(unique.values()).sort(compareVisualLessons);
|
||
}
|
||
|
||
export function naturalCompare(a, b) {
|
||
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
|
||
}
|
||
|
||
export function ruleCountLabel(count) {
|
||
const tail = count % 100;
|
||
if (tail >= 11 && tail <= 14) return 'правил';
|
||
if (count % 10 === 1) return 'правило';
|
||
if ([2, 3, 4].includes(count % 10)) return 'правила';
|
||
return 'правил';
|
||
}
|
||
|
||
export function groupCountLabel(count) {
|
||
const tail = count % 100;
|
||
if (tail >= 11 && tail <= 14) return 'групп';
|
||
if (count % 10 === 1) return 'группа';
|
||
if ([2, 3, 4].includes(count % 10)) return 'группы';
|
||
return 'групп';
|
||
}
|
||
|
||
export function rowCountLabel(count) {
|
||
const tail = count % 100;
|
||
if (tail >= 11 && tail <= 14) return 'строк';
|
||
if (count % 10 === 1) return 'строка';
|
||
if ([2, 3, 4].includes(count % 10)) return 'строки';
|
||
return 'строк';
|
||
}
|
||
|
||
export function mergeEntitiesById(items) {
|
||
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
|
||
}
|
||
|
||
export { formatLocalDate, toIsoDate };
|
||
|
||
// --- Контракты API ---
|
||
|
||
export async function fetchSubjects(client = apiClient) {
|
||
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
|
||
}
|
||
|
||
export async function fetchLessonTypes(client = apiClient) {
|
||
return client.get('/api/lesson-types', { errorMessage: 'Ошибка загрузки типов занятий' });
|
||
}
|
||
|
||
export async function fetchTimeSlots(client = apiClient) {
|
||
const slots = await client.get('/api/admin/time-slots', { errorMessage: 'Ошибка загрузки пар' });
|
||
return (slots || [])
|
||
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
|
||
.sort((first, second) => (first.orderNumber || 0) - (second.orderNumber || 0));
|
||
}
|
||
|
||
export async function fetchAcademicYears(client = apiClient) {
|
||
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
|
||
}
|
||
|
||
export async function fetchApprovedTeacherPreferences(client = apiClient) {
|
||
return client.get('/api/teacher-preferences?status=APPROVED', {
|
||
errorMessage: 'Ошибка загрузки пожеланий преподавателей'
|
||
});
|
||
}
|
||
|
||
export function scheduleRulesPageEndpoint({ versionId, page, size, query }) {
|
||
const params = new URLSearchParams({
|
||
versionId: String(versionId),
|
||
page: String(page),
|
||
size: String(size),
|
||
query: String(query || '').trim()
|
||
});
|
||
return `/api/admin/schedule-rules/page?${params}`;
|
||
}
|
||
|
||
export async function fetchScheduleRulesPage(filters, client = apiClient) {
|
||
return client.get(scheduleRulesPageEndpoint(filters), { errorMessage: 'Ошибка загрузки правил расписания' });
|
||
}
|
||
|
||
export function scheduleRulesEndpoint(versionId) {
|
||
return `/api/admin/schedule-rules?versionId=${encodeURIComponent(versionId)}`;
|
||
}
|
||
|
||
export async function fetchScheduleRules(versionId, client = apiClient) {
|
||
return client.get(scheduleRulesEndpoint(versionId), { errorMessage: 'Ошибка загрузки визуального расписания' });
|
||
}
|
||
|
||
export async function fetchScheduleRule(ruleId, client = apiClient) {
|
||
return client.get(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, {
|
||
errorMessage: 'Ошибка загрузки правила расписания'
|
||
});
|
||
}
|
||
|
||
export async function saveScheduleRule(payload, ruleId, client = apiClient) {
|
||
return ruleId
|
||
? client.put(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, payload, {
|
||
errorMessage: 'Ошибка сохранения правила'
|
||
})
|
||
: client.post('/api/admin/schedule-rules', payload, {
|
||
errorMessage: 'Ошибка сохранения правила'
|
||
});
|
||
}
|
||
|
||
export async function deleteScheduleRule(ruleId, client = apiClient) {
|
||
return client.delete(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, {
|
||
errorMessage: 'Ошибка удаления правила'
|
||
});
|
||
}
|
||
|
||
export function ruleGroupsOptionsEndpoint(query) {
|
||
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
|
||
return `/api/groups/options?${params}`;
|
||
}
|
||
|
||
export async function fetchRuleGroupOptions(query, client = apiClient) {
|
||
return client.get(ruleGroupsOptionsEndpoint(query), { errorMessage: 'Не удалось найти группы' });
|
||
}
|
||
|
||
export function subgroupsEndpoint(groupIds) {
|
||
const params = new URLSearchParams();
|
||
(groupIds || []).forEach(groupId => params.append('groupId', groupId));
|
||
return `/api/subgroups?${params}`;
|
||
}
|
||
|
||
export async function fetchSubgroupsForGroups(groupIds, client = apiClient) {
|
||
return client.get(subgroupsEndpoint(groupIds), { errorMessage: 'Не удалось загрузить подгруппы выбранных групп' });
|
||
}
|