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

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

682 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiClient } from '../shared/api-client.js';
import { 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)));
}