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,292 @@
import { apiClient } from '../shared/api-client.js';
import {
formatDate,
formatIsoDate,
toIsoDate,
trimTime,
weekDates
} from '../shared/dates.js';
export const WORKSPACE_TABS = Object.freeze(['schedule', 'preferences', 'requests']);
export const PREFERENCE_BRUSHES = Object.freeze(['SOFT_PREFERRED', 'SOFT_UNWANTED', 'HARD_UNAVAILABLE']);
export const CHANGE_REQUEST_TYPES = Object.freeze(['MOVE', 'CHANGE_CLASSROOM', 'CANCEL']);
export function lessonKey(lesson) {
return `${lesson.scheduleRuleSlotId}:${lesson.date}`;
}
export function collectSlots(lessons) {
const slots = new Map();
(lessons || []).forEach(lesson => {
slots.set(lesson.timeSlotOrder, {
order: lesson.timeSlotOrder,
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
});
});
if (!slots.size) {
slots.set(1, { order: 1, label: 'Расписание' });
}
return [...slots.values()].sort((left, right) => left.order - right.order);
}
export function buildDesktopWeekModel(weekStart, lessons) {
const slots = collectSlots(lessons);
const lessonsByKey = new Map();
(lessons || []).forEach(lesson => {
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
});
const days = weekDates(weekStart).map(date => ({
date,
isoDate: toIsoDate(date),
label: formatDate(date)
}));
return { days, slots, lessonsByKey };
}
export function lessonsByDate(lessons) {
const byDate = new Map();
(lessons || []).forEach(lesson => {
if (!byDate.has(lesson.date)) byDate.set(lesson.date, []);
byDate.get(lesson.date).push(lesson);
});
return byDate;
}
export function buildTeacherLessonChips(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return [
`${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`,
lesson.lessonTypeName,
subgroups,
lesson.classroomName,
groups
].filter(Boolean);
}
export function canRequestChange(lesson) {
return Boolean(lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId);
}
export function absenceStatusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
RESOLVED: 'Замены выполнены',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function absenceStatusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
export function preferenceTypeLabel(type) {
return {
HARD_UNAVAILABLE: 'Строго недоступен',
SOFT_PREFERRED: 'Предпочтительно',
SOFT_UNWANTED: 'Нежелательно',
CONSECUTIVE: 'Пары подряд',
NO_GAPS: 'Без окон'
}[type] || type || '—';
}
export function preferenceTypeClass(type) {
return {
HARD_UNAVAILABLE: 'is-hard',
SOFT_PREFERRED: 'is-preferred',
SOFT_UNWANTED: 'is-unwanted'
}[type] || '';
}
export function preferenceTypeSymbol(type) {
return { HARD_UNAVAILABLE: '×', SOFT_PREFERRED: '✓', SOFT_UNWANTED: '!' }[type] || '•';
}
export function preferenceStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function changeTypeLabel(type) {
return {
MOVE: 'Перенос',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена'
}[type] || type || '—';
}
export function changeRequestTarget(item) {
if (item.requestType === 'MOVE') {
return `${formatIsoDate(item.targetLessonDate || item.lessonDate)}, ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
}
if (item.requestType === 'CHANGE_CLASSROOM') {
return item.requestedClassroomName;
}
return 'Отмена занятия';
}
export function semesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN'
? 'осенний'
: semester.semesterType === 'SPRING'
? 'весенний'
: semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatIsoDate(semester.startDate)}–${formatIsoDate(semester.endDate)}`;
}
export function resolveCurrentSemester(semesters, todayIso) {
return (semesters || []).find(semester => semester.startDate <= todayIso && semester.endDate >= todayIso) || null;
}
export function resolveSelectedSemester(semesters, semesterId) {
return (semesters || []).find(semester => String(semester.id) === String(semesterId));
}
export function activeWeekPreferences(preferences) {
return (preferences || []).filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
}
export function activeDatePreferences(preferences) {
return (preferences || []).filter(item => item.preferenceDate && item.status !== 'CANCELLED');
}
export function findCompactPreference(preferences, type) {
return (preferences || []).find(
candidate => candidate.preferenceType === type && ['PENDING', 'APPROVED'].includes(candidate.status)
) || null;
}
export function findWeekPreference(preferences, dayOfWeek, timeSlotId) {
return (preferences || []).find(
candidate => ['PENDING', 'APPROVED'].includes(candidate.status)
&& candidate.dayOfWeek === dayOfWeek
&& candidate.timeSlotId === timeSlotId
) || null;
}
export function pendingCount(items) {
return (items || []).filter(item => item.status === 'PENDING').length;
}
export function formatInstant(value) {
if (!value) return '—';
return new Date(value).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
}
export function validateAbsence({ startDate = '', endDate = '', reason = '' } = {}) {
if (!startDate || !endDate || !reason.trim()) {
return 'Заполните период и причину отсутствия';
}
if (endDate < startDate) {
return 'Дата окончания не может быть раньше даты начала';
}
return '';
}
export function buildChangeRequestPayload({ lesson, requestType, targetDate = '', timeSlotId = '', classroomId = '', reason = '' }) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.date,
requestType,
targetLessonDate: requestType === 'MOVE' ? targetDate : null,
requestedTimeSlotId: requestType === 'MOVE' ? Number(timeSlotId) || null : null,
requestedClassroomId: requestType === 'CHANGE_CLASSROOM' ? Number(classroomId) || null : null,
reason: reason.trim()
};
}
export function candidateOptions(items, emptyLabel) {
const available = (items || []).filter(item => item.available);
return available.length > 0 ? available : [{ id: '', label: emptyLabel }];
}
export async function fetchTeacherSchedule({ teacherId, startDate, endDate }, client = apiClient) {
return client.get(
`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`,
{ errorMessage: 'Не удалось загрузить расписание' }
);
}
export async function fetchPreferenceMeta(client = apiClient) {
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить параметры семестра' });
}
export async function fetchPreferences(semesterId, client = apiClient) {
return client.get(
`/api/teacher-preferences?semesterId=${encodeURIComponent(semesterId)}`,
{ errorMessage: 'Не удалось загрузить пожелания' }
);
}
export async function createPreference(payload, client = apiClient) {
return client.post(
'/api/teacher-preferences',
payload,
{ errorMessage: 'Не удалось сохранить пожелание' }
);
}
export async function deletePreference(id, client = apiClient) {
return client.delete(
`/api/teacher-preferences/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось отменить пожелание' }
);
}
export async function fetchChangeRequests(client = apiClient) {
return client.get('/api/teacher-change-requests', { errorMessage: 'Не удалось загрузить заявки' });
}
export async function createChangeRequest(payload, client = apiClient) {
return client.post(
'/api/teacher-change-requests',
payload,
{ errorMessage: 'Не удалось отправить заявку' }
);
}
export async function cancelChangeRequest(id, client = apiClient) {
return client.delete(
`/api/teacher-change-requests/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось отозвать заявку' }
);
}
export async function fetchChangeRequestCandidates({ baseRuleSlotId, lessonDate, targetDate = '' }, client = apiClient) {
const params = new URLSearchParams({
baseRuleSlotId: String(baseRuleSlotId),
lessonDate
});
if (targetDate) params.set('targetDate', targetDate);
return client.get(
`/api/teacher-change-requests/candidates?${params}`,
{ errorMessage: 'Не удалось проверить допустимые варианты' }
);
}
export async function fetchAbsences(client = apiClient) {
return client.get('/api/teacher-absences', { errorMessage: 'Не удалось загрузить заявки об отсутствии' });
}
export async function createAbsence({ startDate, endDate, reason }, client = apiClient) {
return client.post(
'/api/teacher-absences',
{ startDate, endDate, reason },
{ errorMessage: 'Не удалось отправить заявку об отсутствии' }
);
}
export async function cancelAbsence(id, client = apiClient) {
return client.delete(
`/api/teacher-absences/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось отменить заявку' }
);
}