Files
magistr/frontend/react/admin/schedule-service.js
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

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

860 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiClient } from '../shared/api-client.js';
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: 'Не удалось загрузить подгруппы выбранных групп' });
}