Files
magistr/frontend/react/teacher/teacher-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

293 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 {
formatDate,
formatIsoDate,
toIsoDate,
trimTime,
weekDates
} from '../shared/dates.js';
export const WORKSPACE_TABS = Object.freeze(['schedule', 'preferences', 'requests']);
export const PREFERENCE_BRUSHES = Object.freeze(['SOFT_PREFERRED', 'SOFT_UNWANTED', 'HARD_UNAVAILABLE']);
export const CHANGE_REQUEST_TYPES = Object.freeze(['MOVE', 'CHANGE_CLASSROOM', 'CANCEL']);
export function lessonKey(lesson) {
return `${lesson.scheduleRuleSlotId}:${lesson.date}`;
}
export function collectSlots(lessons) {
const slots = new Map();
(lessons || []).forEach(lesson => {
slots.set(lesson.timeSlotOrder, {
order: lesson.timeSlotOrder,
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
});
});
if (!slots.size) {
slots.set(1, { order: 1, label: 'Расписание' });
}
return [...slots.values()].sort((left, right) => left.order - right.order);
}
export function buildDesktopWeekModel(weekStart, lessons) {
const slots = collectSlots(lessons);
const lessonsByKey = new Map();
(lessons || []).forEach(lesson => {
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
});
const days = weekDates(weekStart).map(date => ({
date,
isoDate: toIsoDate(date),
label: formatDate(date)
}));
return { days, slots, lessonsByKey };
}
export function lessonsByDate(lessons) {
const byDate = new Map();
(lessons || []).forEach(lesson => {
if (!byDate.has(lesson.date)) byDate.set(lesson.date, []);
byDate.get(lesson.date).push(lesson);
});
return byDate;
}
export function buildTeacherLessonChips(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return [
`${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`,
lesson.lessonTypeName,
subgroups,
lesson.classroomName,
groups
].filter(Boolean);
}
export function canRequestChange(lesson) {
return Boolean(lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId);
}
export function absenceStatusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
RESOLVED: 'Замены выполнены',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function absenceStatusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
export function preferenceTypeLabel(type) {
return {
HARD_UNAVAILABLE: 'Строго недоступен',
SOFT_PREFERRED: 'Предпочтительно',
SOFT_UNWANTED: 'Нежелательно',
CONSECUTIVE: 'Пары подряд',
NO_GAPS: 'Без окон'
}[type] || type || '—';
}
export function preferenceTypeClass(type) {
return {
HARD_UNAVAILABLE: 'is-hard',
SOFT_PREFERRED: 'is-preferred',
SOFT_UNWANTED: 'is-unwanted'
}[type] || '';
}
export function preferenceTypeSymbol(type) {
return { HARD_UNAVAILABLE: '×', SOFT_PREFERRED: '✓', SOFT_UNWANTED: '!' }[type] || '•';
}
export function preferenceStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function changeTypeLabel(type) {
return {
MOVE: 'Перенос',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена'
}[type] || type || '—';
}
export function changeRequestTarget(item) {
if (item.requestType === 'MOVE') {
return `${formatIsoDate(item.targetLessonDate || item.lessonDate)}, ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
}
if (item.requestType === 'CHANGE_CLASSROOM') {
return item.requestedClassroomName;
}
return 'Отмена занятия';
}
export function semesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN'
? 'осенний'
: semester.semesterType === 'SPRING'
? 'весенний'
: semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatIsoDate(semester.startDate)}–${formatIsoDate(semester.endDate)}`;
}
export function resolveCurrentSemester(semesters, todayIso) {
return (semesters || []).find(semester => semester.startDate <= todayIso && semester.endDate >= todayIso) || null;
}
export function resolveSelectedSemester(semesters, semesterId) {
return (semesters || []).find(semester => String(semester.id) === String(semesterId));
}
export function activeWeekPreferences(preferences) {
return (preferences || []).filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
}
export function activeDatePreferences(preferences) {
return (preferences || []).filter(item => item.preferenceDate && item.status !== 'CANCELLED');
}
export function findCompactPreference(preferences, type) {
return (preferences || []).find(
candidate => candidate.preferenceType === type && ['PENDING', 'APPROVED'].includes(candidate.status)
) || null;
}
export function findWeekPreference(preferences, dayOfWeek, timeSlotId) {
return (preferences || []).find(
candidate => ['PENDING', 'APPROVED'].includes(candidate.status)
&& candidate.dayOfWeek === dayOfWeek
&& candidate.timeSlotId === timeSlotId
) || null;
}
export function pendingCount(items) {
return (items || []).filter(item => item.status === 'PENDING').length;
}
export function formatInstant(value) {
if (!value) return '—';
return new Date(value).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
}
export function validateAbsence({ startDate = '', endDate = '', reason = '' } = {}) {
if (!startDate || !endDate || !reason.trim()) {
return 'Заполните период и причину отсутствия';
}
if (endDate < startDate) {
return 'Дата окончания не может быть раньше даты начала';
}
return '';
}
export function buildChangeRequestPayload({ lesson, requestType, targetDate = '', timeSlotId = '', classroomId = '', reason = '' }) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.date,
requestType,
targetLessonDate: requestType === 'MOVE' ? targetDate : null,
requestedTimeSlotId: requestType === 'MOVE' ? Number(timeSlotId) || null : null,
requestedClassroomId: requestType === 'CHANGE_CLASSROOM' ? Number(classroomId) || null : null,
reason: reason.trim()
};
}
export function candidateOptions(items, emptyLabel) {
const available = (items || []).filter(item => item.available);
return available.length > 0 ? available : [{ id: '', label: emptyLabel }];
}
export async function fetchTeacherSchedule({ teacherId, startDate, endDate }, client = apiClient) {
return client.get(
`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`,
{ errorMessage: 'Не удалось загрузить расписание' }
);
}
export async function fetchPreferenceMeta(client = apiClient) {
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить параметры семестра' });
}
export async function fetchPreferences(semesterId, client = apiClient) {
return client.get(
`/api/teacher-preferences?semesterId=${encodeURIComponent(semesterId)}`,
{ errorMessage: 'Не удалось загрузить пожелания' }
);
}
export async function createPreference(payload, client = apiClient) {
return client.post(
'/api/teacher-preferences',
payload,
{ errorMessage: 'Не удалось сохранить пожелание' }
);
}
export async function deletePreference(id, client = apiClient) {
return client.delete(
`/api/teacher-preferences/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось отменить пожелание' }
);
}
export async function fetchChangeRequests(client = apiClient) {
return client.get('/api/teacher-change-requests', { errorMessage: 'Не удалось загрузить заявки' });
}
export async function createChangeRequest(payload, client = apiClient) {
return client.post(
'/api/teacher-change-requests',
payload,
{ errorMessage: 'Не удалось отправить заявку' }
);
}
export async function cancelChangeRequest(id, client = apiClient) {
return client.delete(
`/api/teacher-change-requests/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось отозвать заявку' }
);
}
export async function fetchChangeRequestCandidates({ baseRuleSlotId, lessonDate, targetDate = '' }, client = apiClient) {
const params = new URLSearchParams({
baseRuleSlotId: String(baseRuleSlotId),
lessonDate
});
if (targetDate) params.set('targetDate', targetDate);
return client.get(
`/api/teacher-change-requests/candidates?${params}`,
{ errorMessage: 'Не удалось проверить допустимые варианты' }
);
}
export async function fetchAbsences(client = apiClient) {
return client.get('/api/teacher-absences', { errorMessage: 'Не удалось загрузить заявки об отсутствии' });
}
export async function createAbsence({ startDate, endDate, reason }, client = apiClient) {
return client.post(
'/api/teacher-absences',
{ startDate, endDate, reason },
{ errorMessage: 'Не удалось отправить заявку об отсутствии' }
);
}
export async function cancelAbsence(id, client = apiClient) {
return client.delete(
`/api/teacher-absences/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось отменить заявку' }
);
}