Files
magistr/frontend/react/admin/teacher-absences-service.js
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

237 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };