import { apiClient } from '../shared/api-client.js'; import { addDays, formatIsoDate, parseIsoDate, toIsoDate, trimTime } from '../shared/dates.js'; export const NO_BUILDING = '__no_building__'; export const DISPLAY_ALL = 'all'; export const TARGET_CLASSROOMS = 'classrooms'; export const TARGET_TEACHERS = 'teachers'; export const TARGET_DEPARTMENTS = 'departments'; const MS_PER_DAY = 24 * 60 * 60 * 1000; export const WORKLOAD_TARGETS = Object.freeze({ [TARGET_CLASSROOMS]: Object.freeze({ allLabel: 'Все аудитории', emptyLabel: 'Нет аудиторий по выбранным фильтрам', rowLabel: 'Аудитория', summaryLabel: 'Аудиторий', periodTitle: 'Нагрузка аудитории по неделям', selectError: 'Выберите аудиторию', prefix: 'room:' }), [TARGET_TEACHERS]: Object.freeze({ allLabel: 'Все преподаватели', emptyLabel: 'Нет преподавателей', rowLabel: 'Преподаватель', summaryLabel: 'Преподавателей', periodTitle: 'Нагрузка преподавателя по неделям', selectError: 'Выберите преподавателя', prefix: 'teacher:' }), [TARGET_DEPARTMENTS]: Object.freeze({ allLabel: 'Все кафедры', emptyLabel: 'Нет кафедр', rowLabel: 'Кафедра', summaryLabel: 'Кафедр', periodTitle: 'Нагрузка кафедры по неделям', selectError: 'Выберите кафедру', prefix: 'department:' }) }); 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: 'Чётная' }) ]); // --- Контракты API --- export function fetchWorkloadClassrooms(client = apiClient) { return client.get('/api/classrooms', { errorMessage: 'Ошибка загрузки аудиторий' }); } export function fetchWorkloadEquipments(client = apiClient) { return client.get('/api/equipments', { errorMessage: 'Ошибка загрузки оборудования' }); } export function fetchWorkloadGroups(client = apiClient) { return client.get('/api/groups', { errorMessage: 'Ошибка загрузки групп' }); } // Справочник учебных годов не критичен для расчёта: сбой молча даёт пустой список. export async function fetchAcademicYears(client = apiClient) { try { return await client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' }); } catch { return []; } } export function fetchWorkloadTeachers(client = apiClient) { return client.get('/api/users/teachers', { errorMessage: 'Ошибка загрузки преподавателей' }); } export function fetchWorkloadDepartments(client = apiClient) { return client.get('/api/departments', { errorMessage: 'Ошибка загрузки кафедр' }); } export function effectiveTimeSlotsEndpoint(date) { return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`; } export function fetchEffectiveTimeSlots(date, client = apiClient) { return client.get(effectiveTimeSlotsEndpoint(date), { errorMessage: 'Ошибка загрузки сетки времени' }); } export function groupScheduleEndpoint(group, startDate, endDate) { return `/api/schedule?${new URLSearchParams({ groupId: String(group.id), startDate, endDate })}`; } export function fetchGroupSchedule(group, startDate, endDate, client = apiClient) { return client.get(groupScheduleEndpoint(group, startDate, endDate), { errorMessage: 'Ошибка загрузки расписания' }); } // --- Справочники --- export function sortClassrooms(classrooms) { return [...(classrooms || [])].sort((a, b) => naturalCompare(a.name, b.name)); } export function sortTeachers(teachers) { return [...(teachers || [])].sort((a, b) => naturalCompare(userName(a), userName(b))); } export function sortDepartments(departments) { return [...(departments || [])].sort((a, b) => naturalCompare(departmentName(a), departmentName(b))); } export function semestersFromYears(academicYears) { return (academicYears || []).flatMap(year => year.semesters || []); } export function userName(user) { return user?.fullName || user?.username || 'Преподаватель без имени'; } export function departmentName(department) { return department?.departmentName || department?.name || 'Кафедра без названия'; } export function buildingKey(room) { return room.building ? String(room.building) : NO_BUILDING; } export function buildingOptions(classrooms) { const values = new Map(); (classrooms || []).forEach(room => { const key = buildingKey(room); values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building); }); return Array.from(values.entries()) .map(([value, label]) => ({ value, label })) .sort((a, b) => naturalCompare(a.label, b.label)); } export function equipmentOptions(equipments, classrooms) { const source = (equipments || []).length ? equipments : (classrooms || []).flatMap(room => room.equipments || []); const values = new Map(); source.forEach(item => { if (item?.id && item?.name) values.set(String(item.id), item.name); }); return Array.from(values.entries()) .map(([id, name]) => ({ id, name })) .sort((a, b) => naturalCompare(a.name, b.name)); } export function entityOptionLabel(target, entity) { if (target === TARGET_TEACHERS) { const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', '); return details ? `${userName(entity)} · ${details}` : userName(entity); } if (target === TARGET_DEPARTMENTS) { const code = entity.departmentCode ? `код ${entity.departmentCode}` : ''; return code ? `${departmentName(entity)} · ${code}` : departmentName(entity); } const details = [entity.building, entity.floor ? `${entity.floor} этаж` : null].filter(Boolean).join(', '); return details ? `${entity.name} · ${details}` : entity.name; } // Части подписи оси таблицы: заголовок, подзаголовок и примечание сущности. export function entityAxisParts(target, entity) { if (target === TARGET_TEACHERS) { return { title: userName(entity), subtitle: entity.jobTitle || 'Должность не указана', note: entity.departmentName || '' }; } if (target === TARGET_DEPARTMENTS) { return { title: departmentName(entity), subtitle: entity.departmentCode ? `Код: ${entity.departmentCode}` : 'Код не указан', note: '' }; } return { title: entity.name, subtitle: entity.capacity ? `${entity.capacity} мест` : 'Вместимость не указана', note: entity.building ? `${entity.building}${entity.floor ? `, ${entity.floor} этаж` : ''}` : '' }; } export function entityName(target, entity) { if (target === TARGET_TEACHERS) return userName(entity); if (target === TARGET_DEPARTMENTS) return departmentName(entity); return entity?.name || 'Аудитория без названия'; } export function entitySummaryDetails(target, entity) { if (target === TARGET_TEACHERS) { return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', '); } if (target === TARGET_DEPARTMENTS) { return entity.departmentCode ? `код ${entity.departmentCode}` : ''; } return [ entity.capacity ? `${entity.capacity} мест` : null, entity.building || null, entity.floor ? `${entity.floor} этаж` : null ].filter(Boolean).join(', '); } // --- Фильтры аудиторий --- export function parseMinCapacity(value) { return parseInt(value || '0', 10) || 0; } export function filteredClassrooms(classrooms, { buildings = [], minCapacity = 0, equipment = [] } = {}) { return (classrooms || []).filter(room => { const buildingMatches = !buildings.length || buildings.includes(buildingKey(room)); const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity; const equipmentMatches = !equipment.length || equipment.every(id => (room.equipments || []).some(item => String(item.id) === String(id)) ); return buildingMatches && capacityMatches && equipmentMatches; }); } // --- Занятия --- export function uniqueLessons(lessons) { const unique = new Map(); (lessons || []).forEach(lesson => { if (!lesson.timeSlotId) return; const key = [ lesson.date, lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', lesson.classroomId, lesson.timeSlotId, lesson.subjectId || '', lesson.teacherId || '' ].join(':'); const existing = unique.get(key); if (existing) { existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames); existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds); existing.subgroupNames = mergeNames(existing.subgroupNames, lesson.subgroupNames); existing.subgroupIds = mergeIds(existing.subgroupIds, lesson.subgroupIds); return; } unique.set(key, { ...lesson, groupNames: [...(lesson.groupNames || [])], groupIds: [...(lesson.groupIds || [])], subgroupNames: [...(lesson.subgroupNames || [])], subgroupIds: [...(lesson.subgroupIds || [])] }); }); return Array.from(unique.values()); } export function teacherDepartmentId(teacherId, teachers, departments) { const teacher = (teachers || []).find(item => sameId(item.id, teacherId)); if (!teacher) return null; if (teacher.departmentId != null) return teacher.departmentId; if (!teacher.departmentName) return null; return (departments || []).find(department => departmentName(department) === teacher.departmentName)?.id ?? null; } export function entityIdsForLesson(lesson, target, teachers, departments) { if (target === TARGET_TEACHERS) { return lesson.teacherId ? [lesson.teacherId] : []; } if (target === TARGET_DEPARTMENTS) { const departmentId = teacherDepartmentId(lesson.teacherId, teachers, departments); return departmentId == null ? [] : [departmentId]; } return lesson.classroomId ? [lesson.classroomId] : []; } export function groupLessonsByEntitySlot(lessons, target, teachers, departments) { const grouped = new Map(); uniqueLessons(lessons).forEach(lesson => { entityIdsForLesson(lesson, target, teachers, departments).forEach(entityId => { const key = entitySlotKey(entityId, lesson.timeSlotId); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(lesson); }); }); grouped.forEach(items => { items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); }); return grouped; } // --- Асинхронные загрузки (повторяют legacy allSettled-стратегию) --- export async function loadSlotsForDates(dates, client = apiClient) { const responses = await Promise.allSettled( dates.map(date => fetchEffectiveTimeSlots(date, client)) ); const slotsByDate = new Map(); responses.forEach((response, index) => { const slots = response.status === 'fulfilled' ? response.value || [] : []; slotsByDate.set(dates[index], [...slots].sort(compareSlots)); }); return { slotsByDate, failedLoads: responses.filter(response => response.status === 'rejected').length }; } export async function loadOverviewLessons({ groups, date, target, teachers, departments }, client = apiClient) { if (!(groups || []).length) { return { lessonsByEntitySlot: new Map(), failedLoads: 0 }; } const responses = await Promise.allSettled( groups.map(group => fetchGroupSchedule(group, date, date, client)) ); const lessons = responses .filter(response => response.status === 'fulfilled') .flatMap(response => response.value || []); return { lessonsByEntitySlot: groupLessonsByEntitySlot(lessons, target, teachers, departments), failedLoads: responses.filter(response => response.status === 'rejected').length }; } export async function loadEntityLessons({ groups, startDate, endDate, entityId, target, teachers, departments }, client = apiClient) { if (!(groups || []).length) { return { lessons: [], lessonsByDateSlot: new Map(), parityLessons: [], failedLoads: 0 }; } const responses = await Promise.allSettled( groups.map(group => fetchGroupSchedule(group, startDate, endDate, client)) ); const parityLessons = responses .filter(response => response.status === 'fulfilled') .flatMap(response => response.value || []); const lessons = uniqueLessons(parityLessons) .filter(lesson => entityIdsForLesson(lesson, target, teachers, departments).some(id => sameId(id, entityId))); const lessonsByDateSlot = new Map(); lessons.forEach(lesson => { const slotKey = lessonSlotKey(lesson); if (!lesson.date || slotKey == null) return; const key = dateSlotKey(lesson.date, slotKey); if (!lessonsByDateSlot.has(key)) lessonsByDateSlot.set(key, []); lessonsByDateSlot.get(key).push(lesson); }); lessonsByDateSlot.forEach(items => { items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); }); return { lessons, lessonsByDateSlot, parityLessons, failedLoads: responses.filter(response => response.status === 'rejected').length }; } // --- Ключи и метки слотов --- export function entitySlotKey(entityId, timeSlotId) { return `${entityId}:${timeSlotId}`; } export function dateSlotKey(date, slotKey) { return `${date}:${slotKey}`; } export function slotOrderKey(slot) { const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id; return value == null ? null : String(value); } export function lessonSlotKey(lesson) { const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId; return value == null ? null : String(value); } export function timeSlotLabel(slot) { return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`; } export function scopeShort(slot) { if (slot.scopeApplyMode === 'WEEKDAY') { return dayShort(slot.scopeDayOfWeek); } return 'Ручн.'; } export function dayShort(dayOfWeek) { return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; } export function isoDay(value) { const day = parseIsoDate(value).getDay(); return day === 0 ? 7 : day; } // --- Двухнедельный период --- 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 datesBetween(startDate, endDate) { const dates = []; for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) { dates.push(toIsoDate(date)); } return dates; } // --- Чётность недель --- export function weekNumberForDate(date, semesters) { const semester = (semesters || []).find(item => item.startDate <= date && item.endDate >= date); if (!semester) return null; const days = Math.floor((parseIsoDate(date) - parseIsoDate(semester.startDate)) / MS_PER_DAY); return Math.floor(days / 7) + 1; } export function parityForDate(date, lessons, semesters) { const lessonParity = (lessons || []).find(lesson => lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity) )?.parity; if (lessonParity) return lessonParity; const weekNumber = weekNumberForDate(date, semesters); if (!weekNumber) return null; return weekNumber % 2 === 0 ? 'EVEN' : 'ODD'; } export function buildDateGridByDay(dates, lessons, semesters) { const datesByDay = {}; let unknownParityDates = 0; dates.forEach((date, index) => { let parity = parityForDate(date, lessons, semesters); if (!parity) { unknownParityDates += 1; parity = index < 7 ? 'ODD' : 'EVEN'; } const day = isoDay(date); if (!datesByDay[day]) datesByDay[day] = {}; datesByDay[day][parity] = date; }); return { datesByDay, unknownParityDates }; } export function uniqueWeekNumbers(dates, semesters) { return Array.from(new Set(dates.map(date => weekNumberForDate(date, semesters)).filter(Boolean))) .sort((a, b) => a - b); } export function roomWeekCaption(datesByDay, semesters) { return CELL_PARITY_LAYOUT .map(({ parity, label }) => { const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean); const weeks = uniqueWeekNumbers(dates, semesters); const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра'; const positionText = parity === 'ODD' ? 'сверху' : 'снизу'; return `${label} ${positionText}, ${weekText}`; }) .join('; '); } // --- Строки слотов сводной недели --- export function buildSlotRows(dates, slotsByDate, lessons) { const rows = new Map(); dates.forEach(date => { (slotsByDate.get(date) || []).forEach(slot => { const key = slotOrderKey(slot); if (key == null) return; putSlotRow(rows, key, slot.orderNumber, slot.startTime, slot.endTime); }); }); (lessons || []).forEach(lesson => { const key = lessonSlotKey(lesson); if (key == null) return; putSlotRow(rows, key, lesson.timeSlotOrder, lesson.startTime, lesson.endTime); }); return Array.from(rows.values()).sort(compareSlotRows); } function putSlotRow(rows, key, orderNumber, startTime, endTime) { const existing = rows.get(key); const next = { key, orderNumber, startTime, endTime, orderLabel: orderNumber ? `${orderNumber} пара` : 'Пара', timeLabel: startTime || endTime ? `${trimTime(startTime)}-${trimTime(endTime)}` : 'Время не указано' }; if (!existing || existing.timeLabel === 'Время не указано') { rows.set(key, next); } } export function hasSlotForDate(date, key, slotsByDate) { return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key); } // --- Состояния ячеек сводной недели --- export function roomSlotState(date, row, slotsByDate, lessonsByDateSlot) { if (!date) { return { kind: 'empty', lessons: [] }; } const key = dateSlotKey(date, row.key); const lessons = lessonsByDateSlot.get(key) || []; const slotExists = hasSlotForDate(date, row.key, slotsByDate) || lessons.length > 0; if (lessons.length) { return { kind: 'busy', lessons }; } if (!slotExists) { return { kind: 'missing', lessons: [] }; } return { kind: 'free', lessons: [] }; } export function roomSlotStatesEqual(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('|'); } function lessonContentSignature(lesson) { return [ lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', lesson.subjectId || lesson.subjectName || '', lesson.teacherId || lesson.teacherName || '', lesson.lessonTypeId || lesson.lessonTypeName || '', lesson.lessonFormat || '', 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 countOccupiedCells(entities, timeSlots, lessonsByEntitySlot) { return (entities || []).reduce((count, entity) => count + (timeSlots || []).filter(slot => (lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0 ).length, 0); } export function countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot) { return (dates || []).reduce((count, date) => { const keys = new Set((slotsByDate.get(date) || []).map(slotOrderKey).filter(key => key != null)); lessonsByDateSlot.forEach((items, key) => { if (items.length && key.startsWith(`${date}:`)) { keys.add(key.slice(date.length + 1)); } }); return count + keys.size; }, 0); } export function countOccupiedRoomCells(lessonsByDateSlot) { return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length; } // --- Сводки --- export function overviewSummaryText({ target, dateValue, entityCount, occupiedCells, totalCells, failedGroupLoads, selectedEntityId }) { const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS]; const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; const date = dateValue ? formatIsoDate(dateValue) : '-'; const day = dateValue ? dayShort(isoDay(dateValue)) : '-'; const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : ''; const selected = selectedEntityId ? ', выбран один объект' : ''; return `Дата: ${date}, ${day}. ${config.summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`; } export function roomSummaryParts({ target, entity, range, totalCells, occupiedCells, failedGroupLoads, failedSlotLoads, unknownParityDates }) { const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; const warnings = []; if (failedGroupLoads) warnings.push(`ошибок групп: ${failedGroupLoads}`); if (failedSlotLoads) warnings.push(`ошибок сеток: ${failedSlotLoads}`); if (unknownParityDates) warnings.push('часть дат вне семестра'); return { name: entityName(target, entity), details: entitySummaryDetails(target, entity), rangeLabel: `${formatIsoDate(range.startDate)} - ${formatIsoDate(range.endDate)}`, occupancyLabel: `Занято: ${occupiedCells} из ${totalCells} (${percent}%)`, warnings: warnings.join(', ') }; } export function formatDateShort(value) { const [, month, day] = String(value).split('-'); return `${day}.${month}`; } // --- Сравнения --- export function compareSlots(a, b) { return Number(a.orderNumber || 0) - Number(b.orderNumber || 0) || naturalCompare(a.startTime, b.startTime); } export function compareSlotRows(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); } export function sameId(first, second) { return String(first) === String(second); } export function naturalCompare(a, b) { return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); } function mergeNames(first = [], second = []) { return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b)); } function mergeIds(first = [], second = []) { return Array.from(new Set([...first, ...second].filter(id => id != null))); }