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,236 @@
import { apiClient } from '../shared/api-client.js';
import { formatLocalDate } from '../../admin/js/utils.js';
// Срез «Отсутствия преподавателей» (этап 5, волна 5): чистая логика и контракты API,
// перенесённые из legacy admin/js/views/teacher-absences.js без изменения поведения.
export const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
// --- Поиск преподавателя для регистрации отсутствия ---
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
const params = new URLSearchParams({ query, limit: '30' });
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
// --- Решения мастера замены ---
export function decisionOptions(lesson) {
const options = [];
(lesson.teacherCandidates || []).forEach(candidate => options.push({
value: `REPLACE_TEACHER|${candidate.id}`,
label: `Заменить: ${candidate.name}`
}));
(lesson.moveCandidates || []).forEach(candidate => options.push({
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
label: `Перенести: ${formatAbsenceDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
}));
(lesson.classroomCandidates || []).forEach(candidate => options.push({
value: `CHANGE_CLASSROOM|${candidate.id}`,
label: `Другая аудитория: ${candidate.name}`
}));
if (lesson.cancellationAvailable) {
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
}
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
return options;
}
export function parseDecisionOption(value) {
const [resolution, first, second] = String(value || '').split('|');
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
if (resolution === 'MOVE_TIME') {
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
}
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
throw new Error('Неизвестное решение мастера замены');
}
// --- Подписи и статусы ---
export function statusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Требует решения',
REJECTED: 'Отклонено',
RESOLVED: 'Разрешено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function statusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
export function genericStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function decisionStatusLabel(status) {
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
}
export function decisionCountLabel(count) {
const value = Number(count) || 0;
const tail = value % 100;
if (tail >= 11 && tail <= 14) return `${value} решений`;
if (value % 10 === 1) return `${value} решение`;
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
return `${value} решений`;
}
export function resolutionLabel(resolution) {
return {
REPLACE_TEACHER: 'Замена преподавателя',
MOVE_TIME: 'Перенос занятия',
CHANGE_CLASSROOM: 'Замена аудитории',
CANCEL: 'Отмена занятия',
REJECT: 'Предложение отклонено'
}[resolution] || resolution || 'Решение';
}
export function changeTypeLabel(type) {
return {
MOVE: 'Перенос даты или времени',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена занятия'
}[type] || type || 'Изменение';
}
export function preferenceDescription(item) {
const type = {
HARD_UNAVAILABLE: 'Строгая недоступность',
SOFT_PREFERRED: 'Предпочтительное время',
SOFT_UNWANTED: 'Нежелательное время',
CONSECUTIVE: 'Пожелание «пары подряд»',
NO_GAPS: 'Пожелание «без окон»'
}[item.preferenceType] || item.preferenceType;
if (item.preferenceDate) return `${type}: ${formatAbsenceDate(item.preferenceDate)}`;
if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`;
return type;
}
export function absenceSemesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatAbsenceDate(semester.startDate)}–${formatAbsenceDate(semester.endDate)}`;
}
export function formatAbsenceDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function dayOfMonth(value) {
return String(value || '').split('-')[2] || '—';
}
export function monthShort(value) {
const month = Number(String(value || '').split('-')[1]);
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
}
export function trimTime(value) {
return String(value || '').slice(0, 5);
}
// --- Сводка и цель заявки ---
export function absenceStats(items) {
const list = Array.isArray(items) ? items : [];
return {
active: list.filter(item => item.status === 'APPROVED').length,
pending: list.filter(item => item.status === 'PENDING').length,
resolved: list.filter(item => item.status === 'RESOLVED').length
};
}
export function changeReviewTarget(item) {
if (item.requestType === 'MOVE') {
return `${formatAbsenceDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
}
if (item.requestType === 'CHANGE_CLASSROOM') return item.requestedClassroomName;
return 'Отмена занятия';
}
// --- Контракты API ---
export function teacherAbsencesEndpoint(status) {
return `/api/teacher-absences${status ? `?status=${encodeURIComponent(status)}` : ''}`;
}
export function teacherPreferencesEndpoint({ semesterId = '', status = '' } = {}) {
const params = new URLSearchParams();
if (semesterId) params.set('semesterId', semesterId);
if (status) params.set('status', status);
return `/api/teacher-preferences${params.size ? `?${params}` : ''}`;
}
export function teacherChangeRequestsEndpoint(status) {
return `/api/teacher-change-requests${status ? `?status=${encodeURIComponent(status)}` : ''}`;
}
export async function fetchTeacherAbsences(status, client = apiClient) {
return client.get(teacherAbsencesEndpoint(status), { errorMessage: 'Не удалось загрузить отсутствия' });
}
export async function createTeacherAbsence(payload, client = apiClient) {
return client.post('/api/teacher-absences', payload, { errorMessage: 'Не удалось зарегистрировать отсутствие' });
}
export async function reviewTeacherAbsence(id, payload, client = apiClient) {
return client.post(`/api/teacher-absences/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось выполнить действие' });
}
export async function deleteTeacherAbsence(id, client = apiClient) {
return client.delete(`/api/teacher-absences/${encodeURIComponent(id)}`, { errorMessage: 'Не удалось выполнить действие' });
}
export async function fetchTeacherAbsenceWizard(id, client = apiClient) {
return client.get(`/api/teacher-absences/${encodeURIComponent(id)}/wizard`, { errorMessage: 'Не удалось открыть мастер замены' });
}
export async function resolveTeacherAbsence(id, decisions, client = apiClient) {
return client.post(
`/api/teacher-absences/${encodeURIComponent(id)}/resolve`,
{ decisions },
{ errorMessage: 'Не удалось применить решения' }
);
}
export async function fetchTeacherPreferenceMeta(client = apiClient) {
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить семестры' });
}
export async function fetchTeacherPreferences(filters, client = apiClient) {
return client.get(teacherPreferencesEndpoint(filters), { errorMessage: 'Не удалось загрузить пожелания' });
}
export async function reviewTeacherPreference(id, payload, client = apiClient) {
return client.post(`/api/teacher-preferences/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось рассмотреть пожелание' });
}
export async function fetchTeacherChangeRequests(status, client = apiClient) {
return client.get(teacherChangeRequestsEndpoint(status), { errorMessage: 'Не удалось загрузить заявки' });
}
export async function reviewTeacherChangeRequest(id, payload, client = apiClient) {
return client.post(`/api/teacher-change-requests/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось применить решение' });
}
export async function fetchAbsenceTeacherOptions({ query, selectedId, role, departmentId }, client = apiClient) {
return client.get(
teacherAbsenceTeacherOptionsUrl({ query, selectedId, role, departmentId }),
{ errorMessage: 'Ошибка поиска преподавателей' }
);
}
export { formatLocalDate };