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
This commit is contained in:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,681 @@
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)));
}