Files
magistr/frontend/react/admin/dashboard-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

233 lines
9.5 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 {
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('');
}