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