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,118 @@
import { apiClient } from '../shared/api-client.js';
export const REQUEST_TABLE_COLUMNS = 7;
export const REQUEST_PAGE_SIZES = [25, 50, 100];
export const REQUEST_STATUS_OPTIONS = Object.freeze([
{ value: 'PENDING', label: 'Ожидают рассмотрения' },
{ value: 'APPROVED', label: 'Одобрены' },
{ value: 'REJECTED', label: 'Отклонены' },
{ value: '', label: 'Все статусы' }
]);
export function normalizeTeacherRequestsState(restored = {}) {
const size = Number(restored.requestSize);
return {
query: restored.requestQuery || '',
status: restored.requestStatus || 'PENDING',
page: Math.max(Number(restored.requestPage) || 0, 0),
size: REQUEST_PAGE_SIZES.includes(size) ? size : 25
};
}
export function initialTeacherRequestsState(restored = {}) {
return normalizeTeacherRequestsState(restored);
}
export function teacherRequestsQueryParams(state) {
const params = new URLSearchParams({
query: state.query || '',
page: String(state.page || 0),
size: String(state.size || 25),
sort: 'createdAt',
direction: 'desc'
});
if (state.status) params.set('status', state.status);
return `/api/teacher-requests/page?${params}`;
}
export function teacherRequestsUrlState(state) {
return {
requestQuery: state.query || '',
requestStatus: state.status === 'PENDING' ? '' : state.status,
requestPage: state.page || '',
requestSize: state.size === 25 ? '' : state.size
};
}
// --- Справочники ---
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
// Кафедра в строке заявки подписывается только названием — как в legacy-реестре.
export function requestDepartmentLabel(department) {
return department.departmentName || department.name || department.id;
}
// --- Реестр заявок ---
export function fetchTeacherRequestsPage(state, client = apiClient) {
return client.get(teacherRequestsQueryParams(state), {
errorMessage: 'Не удалось загрузить заявки преподавателей'
});
}
export function teacherRequestsEmptyMessage(state) {
return state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет';
}
export function reviewedStatusLabel(status) {
return status === 'APPROVED' ? 'Одобрена' : 'Отклонена';
}
// Счётчик в sidebar обновляется событием teacherRequestsChanged: он актуален
// только когда реестр показывает нефильтрованный список ожидающих заявок.
export function pendingCountDetail(state, totalItems) {
return {
count: state.status === 'PENDING' && !state.query
? Number(totalItems || 0)
: undefined
};
}
// --- Одобрение и отклонение ---
export function approvePayloadFromFields(fields) {
const payload = {
departmentId: Number(fields.departmentId),
username: (fields.username || '').trim(),
fullName: (fields.fullName || '').trim(),
jobTitle: (fields.jobTitle || '').trim(),
password: fields.password
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
return { error: 'Заполните кафедру, логин, ФИО и пароль' };
}
if (payload.password.length < 8) {
return { error: 'Пароль должен быть не короче 8 символов' };
}
return { payload };
}
export function approveRequest(id, payload, client = apiClient) {
return client.post(
`/api/teacher-requests/${encodeURIComponent(id)}/approve`,
payload,
{ errorMessage: 'Ошибка одобрения заявки' }
);
}
export function rejectRequest(id, reviewComment, client = apiClient) {
return client.post(
`/api/teacher-requests/${encodeURIComponent(id)}/reject`,
{ reviewComment },
{ errorMessage: 'Ошибка отклонения заявки' }
);
}