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:
232
frontend/react/admin/dashboard-service.js
Normal file
232
frontend/react/admin/dashboard-service.js
Normal 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('');
|
||||
}
|
||||
Reference in New Issue
Block a user