feat: завершить миграцию фронтенда на React (этапы 1-5)

Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,859 @@
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: 'Не удалось загрузить подгруппы выбранных групп' });
}