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,232 @@
import { apiClient } from '../shared/api-client.js';
import {
CONFLICT_CHECK_STATE,
conflictCheckPresentation,
currentWeekDateRange,
formatLocalDate,
loadDepartmentSchedules
} from '../../admin/js/dashboard-conflicts.js';
export { CONFLICT_CHECK_STATE, conflictCheckPresentation, currentWeekDateRange, formatLocalDate };
export async function fetchDashboardReferenceData(client = apiClient) {
const [departments, classrooms, groups, teachers] = await Promise.all([
client.get('/api/departments', { errorMessage: 'Не удалось загрузить кафедры' }),
client.get('/api/classrooms?includeArchived=false', { errorMessage: 'Не удалось загрузить аудитории' }),
client.get('/api/groups?includeArchived=false', { errorMessage: 'Не удалось загрузить группы' }),
client.get('/api/users/teachers', { errorMessage: 'Не удалось загрузить преподавателей' })
]);
return { departments, classrooms, groups, teachers };
}
export function todayScheduleEndpoint(departmentId, dateStr) {
return `/api/schedule/search?departmentId=${departmentId}&startDate=${dateStr}&endDate=${dateStr}`;
}
export async function fetchTodayLessons(departments, dateStr, client = apiClient) {
const loads = (departments || []).map(department =>
client.get(todayScheduleEndpoint(department.id, dateStr)).catch(() => [])
);
const perDepartment = await Promise.all(loads);
return perDepartment.flat();
}
export function weekScheduleEndpoint(departmentId, startDate, endDate) {
return `/api/schedule/search?departmentId=${departmentId}&startDate=${startDate}&endDate=${endDate}`;
}
export async function loadWeekDepartmentSchedules(departments, range, client = apiClient) {
return loadDepartmentSchedules(
departments,
department => client.get(weekScheduleEndpoint(department.id, range.startDate, range.endDate))
);
}
export async function fetchEffectiveTimeSlots(dateStr, client = apiClient) {
try {
return await client.get(`/api/admin/time-slots/effective?date=${dateStr}`);
} catch {
const baseSlots = await client.get('/api/admin/time-slots').catch(() => []);
return (baseSlots || []).filter(slot => slot.scopeId === 1 || !slot.scopeId);
}
}
export function sortTimeSlots(timeSlots) {
return [...(timeSlots || [])].sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
}
export function totalStudents(groups) {
return (groups || []).reduce((total, group) => total + (group.groupSize || 0), 0);
}
export function activeClassrooms(classrooms) {
return (classrooms || []).filter(classroom => classroom.isAvailable && classroom.status !== 'ARCHIVED');
}
export function classroomOccupancyLabel(classrooms, todayLessons) {
const occupied = new Set((todayLessons || []).map(lesson => lesson.classroomId).filter(Boolean));
const active = activeClassrooms(classrooms);
const percent = active.length ? Math.round((occupied.size / active.length) * 100) : 0;
return `${percent}% (${occupied.size}/${active.length})`;
}
export function currentTimeString(date = new Date()) {
return date.toTimeString().split(' ')[0];
}
export function findCurrentSlot(timeSlots, date = new Date()) {
const time = currentTimeString(date);
return (timeSlots || []).find(slot => time >= slot.startTime && time <= slot.endTime) || null;
}
export function currentSlotLabel(slot) {
if (!slot) return 'Вне сетки пар';
return `Пара №${slot.orderNumber} (${slot.startTime.substring(0, 5)} – ${slot.endTime.substring(0, 5)})`;
}
export function lessonsForSlot(lessons, slotOrder) {
return (lessons || []).filter(lesson => lesson.timeSlotOrder === slotOrder);
}
export function freeClassroomsForSlot(classrooms, lessons, slotOrder) {
const active = activeClassrooms(classrooms);
if (slotOrder === null || slotOrder === undefined) return active;
const occupiedIds = new Set(
lessonsForSlot(lessons, slotOrder).map(lesson => lesson.classroomId).filter(Boolean)
);
return active.filter(classroom => !occupiedIds.has(classroom.id));
}
/**
* Выявляет конфликты расписания недели: накладки преподавателей, накладки
* аудиторий и превышение вместимости. Описания возвращаются сегментами,
* где { strong } выделяется полужирным при рендере.
*/
export function detectScheduleConflicts(weekLessons, groups, classrooms) {
const lessons = weekLessons || [];
const groupSizeMap = new Map((groups || []).map(group => [group.id, group.groupSize || 0]));
const classroomCapacityMap = new Map((classrooms || []).map(classroom => [classroom.id, classroom.capacity || 0]));
const conflicts = [];
collectTeacherCollisions(lessons, conflicts);
collectClassroomCollisions(lessons, conflicts);
collectCapacityOverflows(lessons, groupSizeMap, classroomCapacityMap, conflicts);
return conflicts;
}
function collectTeacherCollisions(lessons, conflicts) {
const slots = groupLessons(lessons, lesson => lesson.teacherId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.teacherId}`);
slots.forEach(collisions => {
if (collisions.length < 2) return;
const classroomIds = new Set(collisions.map(lesson => lesson.classroomId));
if (classroomIds.size <= 1) return;
const first = collisions[0];
const list = collisions
.map(lesson => `ауд. ${lesson.classroomName} (группа ${lesson.groupNames.join(', ')})`)
.join(', ');
conflicts.push({
type: 'TEACHER_COLLISION',
title: 'Накладка в расписании преподавателя',
severity: 'error',
segments: [
'Преподаватель ',
{ strong: first.teacherName },
' назначен одновременно в разные места: ',
list,
' на ',
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
'.'
]
});
});
}
function collectClassroomCollisions(lessons, conflicts) {
const slots = groupLessons(lessons, lesson => lesson.classroomId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.classroomId}`);
slots.forEach(collisions => {
if (collisions.length < 2) return;
const teacherIds = new Set(collisions.map(lesson => lesson.teacherId));
const subjectIds = new Set(collisions.map(lesson => lesson.subjectId));
if (teacherIds.size <= 1 && subjectIds.size <= 1) return;
const first = collisions[0];
const details = collisions
.map(lesson => `препод. ${lesson.teacherName} (предмет: ${lesson.subjectName}, группа: ${lesson.groupNames.join(', ')})`)
.join(' И ');
conflicts.push({
type: 'CLASSROOM_COLLISION',
title: 'Накладка в аудитории',
severity: 'error',
segments: [
'Аудитория ',
{ strong: first.classroomName },
' занята одновременно разными занятиями: ',
details,
' на ',
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
'.'
]
});
});
}
function collectCapacityOverflows(lessons, groupSizeMap, classroomCapacityMap, conflicts) {
const slots = groupLessons(lessons, lesson => lesson.classroomId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.classroomId}`);
slots.forEach(collisions => {
if (!collisions.length) return;
const first = collisions[0];
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
const groupIds = new Set();
collisions.forEach(lesson => {
(lesson.groupIds || []).forEach(id => groupIds.add(id));
});
let students = 0;
groupIds.forEach(id => {
students += groupSizeMap.get(id) || 0;
});
if (students <= capacity) return;
const groupNamesList = collisions.map(lesson => lesson.groupNames.join(', ')).join(', ');
conflicts.push({
type: 'CAPACITY_OVERFLOW',
title: 'Превышение вместимости аудитории',
severity: 'warning',
segments: [
'Вместимость аудитории ',
{ strong: first.classroomName },
' составляет ',
{ strong: `${capacity} чел.` },
', однако на занятие назначено ',
{ strong: `${students} человек` },
` (группы: ${groupNamesList}) на `,
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
'.'
]
});
});
}
function groupLessons(lessons, keyOf) {
const slots = new Map();
lessons.forEach(lesson => {
const key = keyOf(lesson);
if (!key) return;
if (!slots.has(key)) slots.set(key, []);
slots.get(key).push(lesson);
});
return slots;
}
export function conflictDescriptionText(conflict) {
return (conflict.segments || [])
.map(segment => (typeof segment === 'string' ? segment : segment.strong))
.join('');
}