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,73 @@
import { apiClient } from '../shared/api-client.js';
export {
addDays,
dayName,
formatDate,
formatWeekLabel,
parseIsoDate,
startOfWeek,
toIsoDate,
trimTime,
weekDates,
weekEndIso,
WEEK_LENGTH_DAYS
} from '../shared/dates.js';
export const OVERVIEW_DAYS = 14;
export const GROUP_STORAGE_KEY = 'studentGroupId';
export function groupLessonsByDate(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 buildLessonChips(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 [
lesson.lessonTypeName,
subgroups,
lesson.lessonFormat,
lesson.teacherName,
lesson.classroomName,
groups
].filter(Boolean);
}
export function buildWeekStatusMessage(lessons) {
const count = Array.isArray(lessons) ? lessons.length : 0;
return count > 0 ? `Найдено занятий: ${count}` : 'На этой неделе занятий нет';
}
export function resolveSelectedGroup(groups, savedGroupId = '') {
if (!Array.isArray(groups) || groups.length === 0) return null;
return groups.find(group => String(group.id) === String(savedGroupId)) || groups[0];
}
export function groupOptionDescription(group) {
return [
group.specialtyCode,
group.specialtyProfileName,
group.course ? `${group.course} курс` : ''
].filter(Boolean).join(' · ');
}
export async function fetchGroupOptions({ query = '', selectedId = '' } = {}, client = apiClient) {
const params = new URLSearchParams({ query, limit: '20' });
if (selectedId) params.set('selectedId', selectedId);
return client.get(`/api/groups/options?${params}`, { errorMessage: 'Не удалось загрузить группы' });
}
export async function fetchSchedule({ groupId, startDate, endDate }, client = apiClient) {
return client.get(
`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`,
{ errorMessage: 'Не удалось загрузить расписание' }
);
}