Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
682 lines
25 KiB
JavaScript
682 lines
25 KiB
JavaScript
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)));
|
||
}
|