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,333 @@
import { apiClient } from '../shared/api-client.js';
import {
addDays,
formatDate,
formatDateShort,
parseIsoDate,
toIsoDate,
trimTime
} from './schedule-view-service.js';
// Волна 6 (этап 5): чистая логика и контракты API разовых правок расписания,
// перенесённые из legacy admin/js/views/schedule-override-panel.js без изменения
// поведения. Императивный модуль панели заменён React-состоянием во вкладке
// просмотра расписаний.
export const OVERRIDE_EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
export const OVERRIDE_ACTION_LABELS = {
MOVE: 'Перенос',
REPLACE: 'Замена',
CANCEL: 'Отмена'
};
export const OVERRIDE_DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
export const OVERRIDE_MONTH_LABELS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
// --- Права и решения ---
export function canEditScheduleOverrides(role) {
return OVERRIDE_EDIT_ROLES.has(role);
}
export function overrideActionForChanges(changes) {
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
return null;
}
// --- Даты и время ---
export function weekDates(value) {
const selected = parseIsoDate(value);
const day = selected.getDay() || 7;
const monday = addDays(selected, 1 - day);
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
}
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
const safeSlots = Array.isArray(slots) ? slots : [];
const byId = preferredId == null
? null
: safeSlots.find(slot => String(slot.id) === String(preferredId));
if (byId) return byId;
const normalizedStart = trimTime(startTime);
const normalizedEnd = trimTime(endTime);
if (!normalizedStart || !normalizedEnd) return null;
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
&& trimTime(slot.endTime) === normalizedEnd) || null;
}
export function overrideIntervalLabel(start, end) {
return `${trimTime(start)}–${trimTime(end)}`;
}
export function overrideTimeSlotLabel(slot) {
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
return `${order}${overrideIntervalLabel(slot.startTime, slot.endTime)}`;
}
// --- Источник правки ---
export function overrideSourceFrom(lesson, existing) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
startTime: existing?.sourceStartTime || lesson.startTime,
endTime: existing?.sourceEndTime || lesson.endTime,
teacherId: existing?.sourceTeacherId || lesson.teacherId,
teacherName: existing?.sourceTeacherName || lesson.teacherName,
classroomId: existing?.sourceClassroomId || lesson.classroomId,
classroomName: existing?.sourceClassroomName || lesson.classroomName,
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
};
}
export function initialTimePreference(existing, source) {
return {
id: existing?.newTimeSlotId || source.timeSlotId,
startTime: existing?.newTimeSlotId ? null : source.startTime,
endTime: existing?.newTimeSlotId ? null : source.endTime
};
}
// Синтетическое занятие для открытия существующей правки из реестра.
export function lessonFromOverride(override, dictionaries) {
return {
scheduleRuleSlotId: override.baseRuleSlotId,
date: override.targetLessonDate || override.lessonDate,
originalLessonDate: override.lessonDate,
scheduleOverrideId: override.id,
subjectName: override.subjectName,
lessonTypeName: override.lessonTypeName,
groupNames: override.groupNames,
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
timeSlotOrder: override.sourceTimeSlotOrder,
startTime: override.sourceStartTime,
endTime: override.sourceEndTime,
teacherId: override.newTeacherId || override.sourceTeacherId,
teacherName: overrideTeacherName(dictionaries, override.newTeacherId) || override.sourceTeacherName,
classroomId: override.newClassroomId || override.sourceClassroomId,
classroomName: overrideClassroomName(dictionaries, override.newClassroomId) || override.sourceClassroomName,
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
};
}
export function overrideTeacherName(dictionaries, id) {
if (id == null || id === '') return '';
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
return teacher?.fullName || teacher?.username || '';
}
export function overrideClassroomName(dictionaries, id) {
if (id == null || id === '') return '';
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
export function classroomOptionLabel(room) {
return room?.name || '';
}
// --- Сравнение «было / станет» ---
export function overrideBeforeMeta(source) {
return [
formatDate(source.lessonDate),
overrideIntervalLabel(source.startTime, source.endTime),
source.teacherName,
source.classroomName,
source.lessonFormat,
source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
export function overrideAfterMeta({ source, targetDate, selectedTime, teacherName, classroomName, lessonFormat }) {
return [
formatDate(targetDate),
selectedTime ? overrideIntervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
teacherName,
classroomName,
lessonFormat,
source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
// --- Календарь семестра ---
export function semesterCalendarMonths(startDate, endDate, available, selectedDate) {
if (!startDate || !endDate) return [];
const start = parseIsoDate(startDate);
const end = parseIsoDate(endDate);
const months = [];
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
cursor <= end;
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(overrideMonth(cursor, startDate, endDate, available, selectedDate));
}
return months;
}
function overrideMonth(month, semesterStart, semesterEnd, available, selectedDate) {
const year = month.getFullYear();
const monthIndex = month.getMonth();
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const blanks = Array.from({ length: firstDay - 1 }, (_, index) => ({ blank: true, key: `blank-${index}` }));
const days = Array.from({ length: dayCount }, (_, index) => {
const date = toIsoDate(new Date(year, monthIndex, index + 1));
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
return {
key: date,
date,
day: index + 1,
enabled,
active: date === selectedDate
};
});
return {
title: `${OVERRIDE_MONTH_LABELS[monthIndex]} ${year}`,
blanks,
days
};
}
// --- Payload правки ---
export function buildOverridePayload({
source,
selectedOverride,
targetDate,
timeSlotId,
teacherId,
classroomId,
lessonFormat,
comment
}) {
if (!timeSlotId) {
return { error: 'Выберите время занятия для выбранной даты' };
}
const dateChanged = targetDate !== source.lessonDate;
const timeChanged = String(timeSlotId) !== String(source.timeSlotId);
const teacherChanged = String(teacherId) !== String(source.teacherId);
const classroomChanged = String(classroomId) !== String(source.classroomId);
const formatChanged = lessonFormat !== source.lessonFormat;
const action = overrideActionForChanges({
dateChanged,
timeChanged,
teacherChanged,
classroomChanged,
formatChanged
});
if (!action) {
return {
error: selectedOverride
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
: 'Измените дату, время, преподавателя, аудиторию или формат'
};
}
return {
payload: {
baseRuleSlotId: source.baseRuleSlotId,
lessonDate: source.lessonDate,
targetLessonDate: dateChanged ? targetDate : null,
action,
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSlotId) : null,
newTeacherId: teacherChanged ? Number(teacherId) : null,
newClassroomId: classroomChanged ? Number(classroomId) : null,
newLessonFormat: formatChanged ? lessonFormat : null,
comment: comment.trim() || null
}
};
}
export function buildCancelOverridePayload(source, comment) {
return {
baseRuleSlotId: source.baseRuleSlotId,
lessonDate: source.lessonDate,
targetLessonDate: null,
action: 'CANCEL',
newTimeSlotId: null,
newTeacherId: null,
newClassroomId: null,
newLessonFormat: null,
comment: comment.trim() || null
};
}
// --- Реестр правок ---
export function overrideRegistryItem(override, dictionaries) {
const target = override.targetLessonDate
? ` → ${formatDate(override.targetLessonDate)}`
: '';
const result = override.action === 'CANCEL'
? 'Занятие отменено'
: [
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
overrideTeacherName(dictionaries, override.newTeacherId),
overrideClassroomName(dictionaries, override.newClassroomId),
override.newLessonFormat
].filter(Boolean).join(' · ');
return {
id: override.id,
actionLabel: OVERRIDE_ACTION_LABELS[override.action] || override.action,
actionClass: `action-${String(override.action).toLowerCase()}`,
timeText: `${formatDate(override.lessonDate)}${target}`,
subject: override.subjectName || 'Занятие',
description: [
override.lessonTypeName,
(override.groupNames || []).join(', '),
result || 'Параметры изменены'
].filter(Boolean).join(' · '),
comment: override.comment || ''
};
}
// --- Контракты API ---
export function overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate) {
return `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(baseRuleSlotId)}`
+ `&lessonDate=${encodeURIComponent(lessonDate)}`;
}
export async function fetchOverrideAvailability(baseRuleSlotId, lessonDate, client = apiClient) {
return client.get(overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate), {
errorMessage: 'Не удалось загрузить доступные даты занятия'
});
}
export function effectiveTimeSlotsEndpoint(date) {
return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`;
}
export async function fetchEffectiveTimeSlots(date, client = apiClient) {
return client.get(effectiveTimeSlotsEndpoint(date), {
errorMessage: 'Не удалось загрузить время занятий'
});
}
export async function saveScheduleOverride(payload, overrideId, client = apiClient) {
return overrideId
? client.put(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, payload, {
errorMessage: 'Не удалось сохранить разовое изменение'
})
: client.post('/api/edu-office/schedule/overrides', payload, {
errorMessage: 'Не удалось сохранить разовое изменение'
});
}
export async function deleteScheduleOverride(overrideId, client = apiClient) {
return client.delete(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, {
errorMessage: 'Не удалось удалить разовое изменение'
});
}