Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
233 lines
9.5 KiB
JavaScript
233 lines
9.5 KiB
JavaScript
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('');
|
||
}
|