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,262 @@
import { apiClient } from '../shared/api-client.js';
import { defaultSchedulePeriod } from '../../admin/js/schedule-period.js';
import { toIsoDate } from '../shared/dates.js';
export const SUBJECT_TABLE_COLUMNS = 4;
export const DEPARTMENT_REQUEST_TABLE_COLUMNS = 6;
// --- Справочники и период ---
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
// Селектор кафедры кабинета подписывает варианты только названием кафедры.
export function departmentOptionLabel(department) {
return department.departmentName || department.name || department.id;
}
export function fetchSemesters(client = apiClient) {
return client.get('/api/schedule/semesters', { errorMessage: 'Не удалось загрузить семестры' });
}
export function defaultWorkloadPeriod(semesters, date = new Date()) {
return defaultSchedulePeriod(semesters, toIsoDate(date));
}
export function fallbackWorkloadPeriod(date = new Date()) {
const end = new Date(date);
end.setDate(date.getDate() + 7);
return { startDate: toIsoDate(date), endDate: toIsoDate(end) };
}
// --- Дисциплины кафедры ---
export function departmentSubjectsEndpoint(departmentId) {
return `/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`;
}
export function fetchDepartmentSubjects(departmentId, client = apiClient) {
return client.get(departmentSubjectsEndpoint(departmentId), {
errorMessage: 'Ошибка загрузки дисциплин'
});
}
export function importSubjectsPayload(rows, departmentId) {
if (!departmentId) return { error: 'Выберите кафедру' };
const payload = (rows || [])
.filter(item => item.name)
.map(item => ({
code: item.code,
name: item.name,
departmentId: Number(departmentId)
}));
if (!payload.length) return { error: 'Заполните название хотя бы одной дисциплины' };
return { payload };
}
export function importSubjects(rows, client = apiClient) {
return client.post('/api/department/subjects/import', rows, {
errorMessage: 'Ошибка загрузки дисциплин'
});
}
// --- Комментарии к дисциплинам ---
export function fetchSubjectComments(subjectId, client = apiClient) {
return client.get(`/api/department/subjects/${encodeURIComponent(subjectId)}/comments`, {
errorMessage: 'Ошибка загрузки комментариев'
});
}
export function saveSubjectComment(subjectId, comment, client = apiClient) {
return client.post(
`/api/department/subjects/${encodeURIComponent(subjectId)}/comments`,
{ comment },
{ errorMessage: 'Ошибка сохранения комментария' }
);
}
// --- Преподаватели и нагрузка ---
export function departmentTeachersEndpoint(departmentId) {
return `/api/department/teachers?departmentId=${encodeURIComponent(departmentId || '')}`;
}
export function fetchDepartmentTeachers(departmentId, client = apiClient) {
return client.get(departmentTeachersEndpoint(departmentId), {
errorMessage: 'Ошибка загрузки преподавателей'
});
}
export function fetchAllTeachers(client = apiClient) {
return client.get('/api/users/teachers', {
errorMessage: 'Ошибка загрузки преподавателей'
});
}
export function availableTeachers(allTeachers, assignedTeachers) {
const assignedIds = new Set((assignedTeachers || []).map(teacher => String(teacher.id)));
return (allTeachers || []).filter(teacher => !assignedIds.has(String(teacher.id)));
}
export function teacherOptionLabel(teacher) {
return teacher.fullName || teacher.username || `ID ${teacher.id}`;
}
export function assignTeacherError(departmentId, teacherId) {
if (!departmentId) return 'Выберите кафедру';
if (!teacherId) return 'Выберите преподавателя';
return null;
}
export function assignTeacher(teacherId, departmentId, comment, client = apiClient) {
return client.post(
`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`,
{
departmentId: Number(departmentId),
comment: comment || null
},
{ errorMessage: 'Ошибка привязки преподавателя' }
);
}
export function teacherWorkloadEndpoint({ startDate, endDate, departmentId }) {
const params = new URLSearchParams({ startDate, endDate });
if (departmentId) params.set('departmentId', departmentId);
return `/api/workload/teachers?${params}`;
}
export function fetchTeacherWorkload(params, client = apiClient) {
return client.get(teacherWorkloadEndpoint(params), {
errorMessage: 'Ошибка расчёта нагрузки'
});
}
// --- Заявки кафедры ---
export function departmentTeacherRequestsEndpoint(departmentId) {
return `/api/department/teacher-requests?departmentId=${encodeURIComponent(departmentId || '')}`;
}
export function fetchDepartmentTeacherRequests(departmentId, client = apiClient) {
return client.get(departmentTeacherRequestsEndpoint(departmentId), {
errorMessage: 'Ошибка загрузки заявок'
});
}
export function teacherRequestPayload(fields, departmentId) {
if (!departmentId) return { error: 'Выберите кафедру' };
const username = (fields.username || '').trim();
const fullName = (fields.fullName || '').trim();
if (!username || !fullName) return { error: 'Заполните логин и ФИО преподавателя' };
return {
payload: {
departmentId: Number(departmentId),
username,
fullName,
jobTitle: (fields.jobTitle || '').trim(),
comment: (fields.comment || '').trim()
}
};
}
export function submitTeacherRequest(payload, client = apiClient) {
return client.post('/api/department/teacher-requests', payload, {
errorMessage: 'Ошибка отправки заявки'
});
}
// --- Метки ---
export function subjectCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
if (count % 10 === 1) return `${count} дисциплина`;
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`;
}
export function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
export function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
export function formatDateTime(value) {
if (!value) return '-';
return String(value).replace('T', ' ').slice(0, 16);
}
// --- Сопоставление преподавателей и нагрузки ---
export function normalizeTeacherName(value) {
return value ? `name:${String(value).trim().toLowerCase()}` : null;
}
export function workloadKeys(item) {
return [
item.id == null ? null : `id:${item.id}`,
normalizeTeacherName(item.fullName || item.name || item.username)
].filter(Boolean);
}
export function workloadLabel(item, errorMessage) {
if (errorMessage) return errorMessage;
if (!item) return '0 ак. ч., 0 занятий за период';
return `${item.academicHours ?? 0} ак. ч., ${item.lessonCount ?? 0} занятий за период`;
}
// Воспроизводит legacy-логику сводки: преподаватели кафедры, затем записи
// нагрузки, не привязанные ни к одному преподавателю (есть занятия в расписании).
export function teacherMetrics(teachers, workload) {
const workloadByTeacher = new Map();
const usedWorkloadKeys = new Set();
(workload || [])
.filter(item => item && (item.id || item.name))
.forEach(item => {
workloadKeys(item).forEach(key => {
if (!workloadByTeacher.has(key)) {
workloadByTeacher.set(key, item);
}
});
});
const rows = (teachers || []).map(teacher => {
const workloadItem = workloadKeys(teacher)
.map(key => workloadByTeacher.get(key))
.find(Boolean);
if (workloadItem) {
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
}
return {
name: teacher.fullName || teacher.username || '-',
meta: teacher.jobTitle || 'Должность не указана',
workload: workloadItem
};
});
(workload || [])
.filter(item => item && (item.id || item.name))
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
.forEach(item => {
rows.push({
name: item.name || '-',
meta: 'Есть занятия в расписании кафедры',
workload: item
});
});
return rows;
}