Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
263 lines
9.4 KiB
JavaScript
263 lines
9.4 KiB
JavaScript
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;
|
||
}
|