import { apiClient } from '../shared/api-client.js'; // Срез «Просмотр расписаний» (этап 5, волна 5): чистая логика и контракты API, // перенесённые из legacy admin/js/views/schedule-view.js без изменения поведения. export const ROLE_DEPARTMENT = 'DEPARTMENT'; export const TARGET_GROUPS = 'groups'; export const TARGET_TEACHERS = 'teachers'; export const TARGET_CLASSROOMS = 'classrooms'; export const TARGET_DEPARTMENTS = 'departments'; export const PARITY_LABELS = Object.freeze({ BOTH: 'каждая', ODD: 'нечётная', EVEN: 'чётная' }); export const DAY_SHORT_LABELS = Object.freeze({ 1: 'Пн', 2: 'Вт', 3: 'Ср', 4: 'Чт', 5: 'Пт', 6: 'Сб', 7: 'Вс' }); export const DAY_FULL_LABELS = Object.freeze({ 1: 'Понедельник', 2: 'Вторник', 3: 'Среда', 4: 'Четверг', 5: 'Пятница', 6: 'Суббота', 7: 'Воскресенье' }); export const DAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 7]); export const CELL_PARITY_LAYOUT = Object.freeze([ Object.freeze({ parity: 'ODD', label: 'Нечётная' }), Object.freeze({ parity: 'EVEN', label: 'Чётная' }) ]); export const MOBILE_QUERY = '(max-width: 760px)'; export const DEFAULT_SEMESTER_WEEKS = 22; export const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание'; // --- Дата и время --- export function parseIsoDate(value) { const [year, month, day] = String(value).split('-').map(Number); return new Date(year, month - 1, day); } export function toIsoDate(date) { return [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0') ].join('-'); } export function addDays(date, days) { const next = new Date(date); next.setDate(next.getDate() + days); return next; } export function isoDay(value) { const day = parseIsoDate(value).getDay(); return day === 0 ? 7 : day; } export function datesBetween(startDate, endDate) { const dates = []; for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) { dates.push(toIsoDate(date)); } return dates; } export function weekStart(value) { const date = parseIsoDate(value); const day = date.getDay() || 7; date.setDate(date.getDate() + 1 - day); return toIsoDate(date); } export function twoWeekRange(value) { const selected = parseIsoDate(value); const start = addDays(selected, 1 - isoDay(value)); const end = addDays(start, 13); return { startDate: toIsoDate(start), endDate: toIsoDate(end) }; } export function formatDate(value) { if (!value) return '-'; const [year, month, day] = String(value).split('-'); return `${day}.${month}.${year}`; } export function formatDateShort(value) { const [, month, day] = String(value).split('-'); return `${day}.${month}`; } export function trimTime(value) { return String(value || '').slice(0, 5); } export function dayShort(dayOfWeek) { return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; } // --- Семестры и периоды --- export function semesterOptionLabel(semester) { const type = String(semester?.semesterType || '').toLowerCase(); const typeLabel = type === 'autumn' ? 'Осенний семестр' : (type === 'spring' ? 'Весенний семестр' : 'Семестр'); const academicYear = semester?.academicYearTitle || 'Учебный год'; const dates = semester?.startDate && semester?.endDate ? ` · ${formatDate(semester.startDate)} — ${formatDate(semester.endDate)}` : ''; return `${academicYear} · ${typeLabel}${dates}`; } export function findSemesterForDate(semesters, date) { return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null; } export function scheduleRangeForSemester(selectedDate, semester) { if (semester?.startDate && semester?.endDate) { return { startDate: semester.startDate, endDate: semester.endDate }; } return twoWeekRange(selectedDate); } export function dateWithinSemester(date, semester) { return Boolean( date && semester?.startDate && semester?.endDate && date >= semester.startDate && date <= semester.endDate ); } export function semesterWeekCount(semester) { if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS; const start = parseIsoDate(semester.startDate); const end = parseIsoDate(semester.endDate); const days = Math.max(1, Math.round((end - start) / 86400000) + 1); return Math.max(1, Math.ceil(days / 7)); } export function semesterScheduleCaption(semester) { const weeks = semesterWeekCount(semester); const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`; return `Весь семестр · ${weekLabel}`; } export function periodCaption(range, semester = null) { if (!range) return 'Учебное расписание'; const rangeLabel = `${formatDate(range.startDate)} — ${formatDate(range.endDate)}`; return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel; } export function formatLessonWeeksText(startWeek, endWeek) { const start = Number(startWeek); const end = Number(endWeek); if (!Number.isFinite(start) || !Number.isFinite(end)) return ''; return start === end ? `(${start} нед.)` : `(с ${start} по ${end} нед.)`; } // Диапазон недель занятия: явные границы из матрицы либо расчёт по часам. export function getLessonWeeksText(lesson) { if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) { return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek); } if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) { return ''; } const totalHours = lesson.lessonTypeAcademicHours; const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0; // Каждое занятие - это 2 часа const totalLessons = Math.ceil(totalHours / 2); if (totalLessons <= 0) return ''; const parity = lesson.ruleParity || lesson.parity; let startWeek; let endWeek; if (parity === 'EVEN' || parity === 'ODD') { // Раз в две недели startWeek = lesson.weekNumber - consumedBefore; endWeek = startWeek + (totalLessons - 1) * 2; } else { // Каждую неделю const steps = Math.floor(consumedBefore / 2); startWeek = lesson.weekNumber - steps; endWeek = startWeek + totalLessons - 1; } if (startWeek <= 0) startWeek = 1; if (endWeek < startWeek) endWeek = startWeek; return formatLessonWeeksText(startWeek, endWeek); } // --- Разделы расписания --- export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) { return target === TARGET_GROUPS && selectedGroupId ? new Set([String(selectedGroupId)]) : new Set(); } export function splitTypeForTarget(target) { if (target === TARGET_TEACHERS) return TARGET_TEACHERS; if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS; return TARGET_GROUPS; } export function buildSectionContext({ target, selectedGroupId, groups }) { const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId); const groupNamesById = new Map((groups || []).map(group => [String(group.id), group.name])); return { selectedGroupId, selectedGroupName: selectedGroupId ? (groupNamesById.get(String(selectedGroupId)) || `Группа ${selectedGroupId}`) : '', allowedGroupIds, groupNamesById }; } export function buildScheduleSections(lessons, splitType, context) { const sections = new Map(); lessons.forEach(lesson => { splitEntitiesForLesson(lesson, splitType, context).forEach(entity => { if (!sections.has(entity.key)) { sections.set(entity.key, { key: entity.key, title: entity.title, lessons: [] }); } sections.get(entity.key).lessons.push(lesson); }); }); return Array.from(sections.values()) .map(section => ({ ...section, lessons: sortLessons(section.lessons) })) .sort((a, b) => naturalCompare(a.title, b.title)); } function splitEntitiesForLesson(lesson, splitType, context) { if (splitType === TARGET_TEACHERS) { return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; } if (splitType === TARGET_CLASSROOMS) { return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; } if (context.selectedGroupId) { return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')]; } return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById); } function singleEntity(prefix, id, name, fallback) { const title = name || fallback; return { id, key: `${prefix}:${id ?? title}`, title }; } function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) { const safeIds = Array.isArray(ids) ? ids : []; const safeNames = Array.isArray(names) ? names : []; if (!safeIds.length && !safeNames.length) { return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)]; } const maxLength = Math.max(safeIds.length, safeNames.length); const entities = []; for (let index = 0; index < maxLength; index += 1) { const id = safeIds[index] ?? null; if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) { continue; } const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`); entities.push(singleEntity(prefix, id, name, fallback)); } return entities; } export function sortLessons(lessons) { return [...lessons].sort((a, b) => naturalCompare(a.date, b.date) || compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b)) || naturalCompare(a.subjectName, b.subjectName) || naturalCompare(a.teacherName, b.teacherName) || naturalCompare(a.classroomName, b.classroomName) ); } function lessonSlotDescriptor(lesson) { return { key: lessonSlotKey(lesson), orderNumber: lesson.timeSlotOrder, startTime: lesson.startTime }; } // --- Слоты и сетки --- export function lessonSlotKey(lesson) { const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`; return value ? String(value) : 'unknown'; } export function dateSlotKey(date, slotKey) { return `${date}:${slotKey}`; } export function collectSlots(lessons) { const slots = new Map(); lessons.forEach(lesson => { const key = lessonSlotKey(lesson); const existing = slots.get(key); const next = { key, id: lesson.timeSlotId, orderNumber: lesson.timeSlotOrder, startTime: lesson.startTime, endTime: lesson.endTime, orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара', timeLabel: lesson.startTime || lesson.endTime ? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}` : 'Время не указано' }; if (!existing || existing.timeLabel === 'Время не указано') { slots.set(key, next); } }); return Array.from(slots.values()).sort(compareSlots); } export function groupLessonsByDateSlot(lessons) { const grouped = new Map(); lessons.forEach(lesson => { if (!lesson.date) return; const key = dateSlotKey(lesson.date, lessonSlotKey(lesson)); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(lesson); }); grouped.forEach(items => { items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName) || naturalCompare(a.teacherName, b.teacherName) || naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(','))); }); return grouped; } export function buildCombinedWeekBlocks(startDate, endDate, lessons) { const dates = datesBetween(startDate, endDate); const weekGroups = new Map(); dates.forEach(date => { const key = weekStart(date); if (!weekGroups.has(key)) weekGroups.set(key, []); weekGroups.get(key).push(date); }); const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b)); const blocks = []; for (let index = 0; index < weekStarts.length; index += 2) { const firstWeek = weekGroups.get(weekStarts[index]) || []; const secondWeek = weekGroups.get(weekStarts[index + 1]) || []; const blockDates = [...firstWeek, ...secondWeek]; const firstParity = weekParity(firstWeek, lessons) || 'ODD'; const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD'); const datesWithFallback = [ ...firstWeek.map(date => ({ date, fallbackParity: firstParity })), ...secondWeek.map(date => ({ date, fallbackParity: secondParity })) ]; blocks.push({ startDate: blockDates[0], endDate: blockDates[blockDates.length - 1], datesByDay: buildDatesByDay(datesWithFallback, lessons), label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}` }); } return blocks; } function weekParity(dates, lessons) { for (const date of dates) { const parity = parityForDate(date, lessons); if (parity) return parity; } return null; } export function buildDatesByDay(datesWithFallback, lessons) { const datesByDay = {}; datesWithFallback.forEach(({ date, fallbackParity }) => { const day = isoDay(date); const parity = parityForDate(date, lessons) || fallbackParity; if (!datesByDay[day]) datesByDay[day] = {}; datesByDay[day][parity] = date; }); return datesByDay; } export function parityForDate(date, lessons) { const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity)); if (lesson?.parity) return lesson.parity; const withWeek = lessons.find(item => item.date === date && item.weekNumber != null); if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD'; return null; } export function combinedWeekCaption() { return 'Нечётная и чётная недели'; } export function mobileDayCaption() { return 'Нечётная / Чётная'; } // --- Матрица семестра --- export function buildSemesterLessonMatrix(lessons) { const weekRanges = new Map(); lessons.forEach(lesson => { const signature = lessonContentSignature(lesson); const weekNumber = Number(lesson.weekNumber); if (!Number.isFinite(weekNumber)) return; const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber }; range.startWeek = Math.min(range.startWeek, weekNumber); range.endWeek = Math.max(range.endWeek, weekNumber); weekRanges.set(signature, range); }); const matrix = new Map(); lessons.forEach(lesson => { const day = Number(lesson.dayOfWeek) || isoDay(lesson.date); const parity = ['ODD', 'EVEN'].includes(lesson.parity) ? lesson.parity : (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD'); const signature = lessonContentSignature(lesson); const key = `${day}:${lessonSlotKey(lesson)}:${parity}`; if (!matrix.has(key)) matrix.set(key, new Map()); if (!matrix.get(key).has(signature)) { const range = weekRanges.get(signature); matrix.get(key).set(signature, { ...lesson, displayStartWeek: range?.startWeek, displayEndWeek: range?.endWeek }); } }); return new Map(Array.from(matrix.entries()).map(([key, values]) => [ key, Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName)) ])); } export function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ parity, label, state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) })); } export function slotStatesForSemester(day, slot, lessonMatrix) { return CELL_PARITY_LAYOUT.map(({ parity, label }) => { const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || []; return { parity, label, state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] } }; }); } export function scheduleSlotState(date, slot, lessonsByDateSlot) { if (!date) { return { kind: 'empty', lessons: [] }; } const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || []; if (lessons.length) { return { kind: 'busy', lessons }; } return { kind: 'free', lessons: [] }; } export function slotStatesEqual(first, second) { if (first.kind !== second.kind) return false; if (first.kind !== 'busy') return true; return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons); } function lessonSetSignature(lessons) { return lessons .map(lessonContentSignature) .sort((a, b) => naturalCompare(a, b)) .join('|'); } export function lessonContentSignature(lesson) { return [ lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', lesson.subjectId || lesson.subjectName || '', lesson.teacherId || lesson.teacherName || '', lesson.classroomId || lesson.classroomName || '', lesson.lessonTypeId || lesson.lessonTypeName || '', lesson.lessonFormat || '', lesson.scheduleOverrideId || '', lesson.overrideAction || '', lesson.originalLessonDate || '', valueListSignature(lesson.groupIds, lesson.groupNames), valueListSignature(lesson.subgroupIds, lesson.subgroupNames) ].join(':'); } function valueListSignature(primary = [], fallback = []) { const primaryList = Array.isArray(primary) ? primary : []; const fallbackList = Array.isArray(fallback) ? fallback : []; const source = primaryList.length ? primaryList : fallbackList; return [...source] .map(value => String(value)) .sort((a, b) => naturalCompare(a, b)) .join(','); } // --- Подписи --- export function lessonCountLabel(count) { const tail = count % 100; if (tail >= 11 && tail <= 14) return `${count} занятий`; if (count % 10 === 1) return `${count} занятие`; if ([2, 3, 4].includes(count % 10)) return `${count} занятия`; return `${count} занятий`; } export function sectionCountLabel(count) { const tail = count % 100; if (tail >= 11 && tail <= 14) return `${count} расписаний`; if (count % 10 === 1) return `${count} расписание`; if ([2, 3, 4].includes(count % 10)) return `${count} расписания`; return `${count} расписаний`; } // --- Справочники --- export function roomLabel(room) { return [room?.name, room?.building, room?.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); } export function groupOptionLabel(group) { return group?.name || ''; } export function teacherOptionLabel(teacher) { return teacher?.fullName || teacher?.username || ''; } export function mergeDictionaryItems(dictionaries, key, items) { const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item])); (items || []).forEach(item => merged.set(String(item.id), item)); dictionaries[key] = [...merged.values()]; } // --- Контракты API --- export async function fetchScheduleViewDictionaries(client = apiClient) { const [departments, subjects, lessonTypes, semesters] = await Promise.all([ client.get('/api/departments', { errorMessage: 'Ошибка загрузки кафедр' }), client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' }), client.get('/api/lesson-types', { errorMessage: 'Ошибка загрузки типов занятий' }), client.get('/api/schedule/semesters', { errorMessage: 'Ошибка загрузки семестров' }) ]); return { groups: [], teachers: [], classrooms: [], departments: departments || [], subjects: subjects || [], lessonTypes: lessonTypes || [], semesters: semesters || [] }; } export function scheduleSearchEndpoint(filters) { const params = new URLSearchParams({ startDate: filters.startDate, endDate: filters.endDate }); applyTargetFilter(params, filters); addOptional(params, 'subjectId', filters.subjectId); addOptional(params, 'lessonTypeId', filters.lessonTypeId); addOptional(params, 'parity', filters.parity); return `/api/schedule/search?${params}`; } // Backend отвергает поиск сразу по всем активным группам («Уточните группу // или кафедру»), поэтому запрос без выбранной цели заведомо вернёт 400. // Используется, чтобы не показывать ошибку там, где достаточно подсказки. export function hasScheduleSearchTarget(filters) { if (filters.role === ROLE_DEPARTMENT) { return Boolean(filters.userDepartmentId); } if (filters.target === TARGET_GROUPS) { return Boolean(filters.groupId); } if (filters.target === TARGET_TEACHERS) { return Boolean(filters.teacherId); } if (filters.target === TARGET_CLASSROOMS) { return Boolean(filters.classroomId); } if (filters.target === TARGET_DEPARTMENTS) { return Boolean(filters.departmentId); } return false; } function applyTargetFilter(params, filters) { if (filters.role === ROLE_DEPARTMENT) { params.set('departmentId', filters.userDepartmentId); return; } if (filters.target === TARGET_GROUPS) { addOptional(params, 'groupId', filters.groupId); return; } if (filters.target === TARGET_TEACHERS) { addOptional(params, 'teacherId', filters.teacherId); return; } if (filters.target === TARGET_CLASSROOMS) { addOptional(params, 'classroomId', filters.classroomId); return; } if (filters.target === TARGET_DEPARTMENTS) { addOptional(params, 'departmentId', filters.departmentId); } } function addOptional(params, key, value) { if (value) params.set(key, value); } export async function fetchScheduleLessons(filters, client = apiClient) { return client.get(scheduleSearchEndpoint(filters), { errorMessage: 'Ошибка загрузки расписания' }); } export function scheduleOverridesEndpoint(range) { return `/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`; } export async function fetchScheduleOverrides(range, client = apiClient) { return client.get(scheduleOverridesEndpoint(range), { errorMessage: 'Ошибка загрузки разовых изменений' }); } export function groupsOptionsEndpoint(query, selectedId, departmentId = '') { const params = new URLSearchParams({ query, limit: '30' }); if (selectedId) params.set('selectedId', selectedId); if (departmentId) params.set('departmentId', departmentId); return `/api/groups/options?${params}`; } export function teachersOptionsEndpoint(query, selectedId) { const params = new URLSearchParams({ query, limit: '30' }); if (selectedId) params.set('selectedId', selectedId); return `/api/users/teachers/options?${params}`; } export function classroomsOptionsEndpoint(query, selectedId) { const params = new URLSearchParams({ query, limit: '30' }); if (selectedId) params.set('selectedId', selectedId); return `/api/classrooms/options?${params}`; } export async function fetchGroupOptions(query, selectedId, departmentId, client = apiClient) { return client.get(groupsOptionsEndpoint(query, selectedId, departmentId), { errorMessage: 'Ошибка поиска групп' }); } export async function fetchTeacherOptions(query, selectedId, client = apiClient) { return client.get(teachersOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска преподавателей' }); } export async function fetchClassroomOptions(query, selectedId, client = apiClient) { return client.get(classroomsOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска аудиторий' }); } // --- Состояние фильтров в URL --- export function initialScheduleViewState(values = {}) { return { target: values.scheduleTarget || TARGET_GROUPS, entity: values.scheduleEntity || '', date: values.scheduleDate || '', yearId: values.scheduleYear || '', semesterId: values.scheduleSemester || '', subjectId: values.scheduleSubject || '', lessonTypeId: values.scheduleLessonType || '', parity: values.scheduleParity || '' }; } export function entityValueForTarget(target, entityIds = {}) { if (target === TARGET_GROUPS) return entityIds.groupId || ''; if (target === TARGET_TEACHERS) return entityIds.teacherId || ''; if (target === TARGET_CLASSROOMS) return entityIds.classroomId || ''; if (target === TARGET_DEPARTMENTS) return entityIds.departmentId || ''; return ''; } export function scheduleViewUrlState(filters) { return { scheduleTarget: filters.role === ROLE_DEPARTMENT ? '' : filters.target, scheduleEntity: filters.entityValue || '', scheduleDate: filters.date || '', scheduleYear: filters.yearId || '', scheduleSemester: filters.semesterId || '', scheduleSubject: filters.subjectId || '', scheduleLessonType: filters.lessonTypeId || '', scheduleParity: filters.parity || '' }; } // --- Служебные сравнения --- function compareSlots(a, b) { const first = Number(a.orderNumber || a.key); const second = Number(b.orderNumber || b.key); if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) { return first - second; } return naturalCompare(a.startTime, b.startTime) || naturalCompare(a.key, b.key); } function naturalCompare(a, b) { return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); }