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