Files
magistr/frontend/react/admin/schedule-override-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

334 lines
13 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 {
addDays,
formatDate,
formatDateShort,
parseIsoDate,
toIsoDate,
trimTime
} from './schedule-view-service.js';
// Волна 6 (этап 5): чистая логика и контракты API разовых правок расписания,
// перенесённые из legacy admin/js/views/schedule-override-panel.js без изменения
// поведения. Императивный модуль панели заменён React-состоянием во вкладке
// просмотра расписаний.
export const OVERRIDE_EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
export const OVERRIDE_ACTION_LABELS = {
MOVE: 'Перенос',
REPLACE: 'Замена',
CANCEL: 'Отмена'
};
export const OVERRIDE_DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
export const OVERRIDE_MONTH_LABELS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
// --- Права и решения ---
export function canEditScheduleOverrides(role) {
return OVERRIDE_EDIT_ROLES.has(role);
}
export function overrideActionForChanges(changes) {
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
return null;
}
// --- Даты и время ---
export function weekDates(value) {
const selected = parseIsoDate(value);
const day = selected.getDay() || 7;
const monday = addDays(selected, 1 - day);
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
}
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
const safeSlots = Array.isArray(slots) ? slots : [];
const byId = preferredId == null
? null
: safeSlots.find(slot => String(slot.id) === String(preferredId));
if (byId) return byId;
const normalizedStart = trimTime(startTime);
const normalizedEnd = trimTime(endTime);
if (!normalizedStart || !normalizedEnd) return null;
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
&& trimTime(slot.endTime) === normalizedEnd) || null;
}
export function overrideIntervalLabel(start, end) {
return `${trimTime(start)}–${trimTime(end)}`;
}
export function overrideTimeSlotLabel(slot) {
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
return `${order}${overrideIntervalLabel(slot.startTime, slot.endTime)}`;
}
// --- Источник правки ---
export function overrideSourceFrom(lesson, existing) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
startTime: existing?.sourceStartTime || lesson.startTime,
endTime: existing?.sourceEndTime || lesson.endTime,
teacherId: existing?.sourceTeacherId || lesson.teacherId,
teacherName: existing?.sourceTeacherName || lesson.teacherName,
classroomId: existing?.sourceClassroomId || lesson.classroomId,
classroomName: existing?.sourceClassroomName || lesson.classroomName,
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
};
}
export function initialTimePreference(existing, source) {
return {
id: existing?.newTimeSlotId || source.timeSlotId,
startTime: existing?.newTimeSlotId ? null : source.startTime,
endTime: existing?.newTimeSlotId ? null : source.endTime
};
}
// Синтетическое занятие для открытия существующей правки из реестра.
export function lessonFromOverride(override, dictionaries) {
return {
scheduleRuleSlotId: override.baseRuleSlotId,
date: override.targetLessonDate || override.lessonDate,
originalLessonDate: override.lessonDate,
scheduleOverrideId: override.id,
subjectName: override.subjectName,
lessonTypeName: override.lessonTypeName,
groupNames: override.groupNames,
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
timeSlotOrder: override.sourceTimeSlotOrder,
startTime: override.sourceStartTime,
endTime: override.sourceEndTime,
teacherId: override.newTeacherId || override.sourceTeacherId,
teacherName: overrideTeacherName(dictionaries, override.newTeacherId) || override.sourceTeacherName,
classroomId: override.newClassroomId || override.sourceClassroomId,
classroomName: overrideClassroomName(dictionaries, override.newClassroomId) || override.sourceClassroomName,
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
};
}
export function overrideTeacherName(dictionaries, id) {
if (id == null || id === '') return '';
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
return teacher?.fullName || teacher?.username || '';
}
export function overrideClassroomName(dictionaries, id) {
if (id == null || id === '') return '';
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
export function classroomOptionLabel(room) {
return room?.name || '';
}
// --- Сравнение «было / станет» ---
export function overrideBeforeMeta(source) {
return [
formatDate(source.lessonDate),
overrideIntervalLabel(source.startTime, source.endTime),
source.teacherName,
source.classroomName,
source.lessonFormat,
source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
export function overrideAfterMeta({ source, targetDate, selectedTime, teacherName, classroomName, lessonFormat }) {
return [
formatDate(targetDate),
selectedTime ? overrideIntervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
teacherName,
classroomName,
lessonFormat,
source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
// --- Календарь семестра ---
export function semesterCalendarMonths(startDate, endDate, available, selectedDate) {
if (!startDate || !endDate) return [];
const start = parseIsoDate(startDate);
const end = parseIsoDate(endDate);
const months = [];
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
cursor <= end;
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(overrideMonth(cursor, startDate, endDate, available, selectedDate));
}
return months;
}
function overrideMonth(month, semesterStart, semesterEnd, available, selectedDate) {
const year = month.getFullYear();
const monthIndex = month.getMonth();
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const blanks = Array.from({ length: firstDay - 1 }, (_, index) => ({ blank: true, key: `blank-${index}` }));
const days = Array.from({ length: dayCount }, (_, index) => {
const date = toIsoDate(new Date(year, monthIndex, index + 1));
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
return {
key: date,
date,
day: index + 1,
enabled,
active: date === selectedDate
};
});
return {
title: `${OVERRIDE_MONTH_LABELS[monthIndex]} ${year}`,
blanks,
days
};
}
// --- Payload правки ---
export function buildOverridePayload({
source,
selectedOverride,
targetDate,
timeSlotId,
teacherId,
classroomId,
lessonFormat,
comment
}) {
if (!timeSlotId) {
return { error: 'Выберите время занятия для выбранной даты' };
}
const dateChanged = targetDate !== source.lessonDate;
const timeChanged = String(timeSlotId) !== String(source.timeSlotId);
const teacherChanged = String(teacherId) !== String(source.teacherId);
const classroomChanged = String(classroomId) !== String(source.classroomId);
const formatChanged = lessonFormat !== source.lessonFormat;
const action = overrideActionForChanges({
dateChanged,
timeChanged,
teacherChanged,
classroomChanged,
formatChanged
});
if (!action) {
return {
error: selectedOverride
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
: 'Измените дату, время, преподавателя, аудиторию или формат'
};
}
return {
payload: {
baseRuleSlotId: source.baseRuleSlotId,
lessonDate: source.lessonDate,
targetLessonDate: dateChanged ? targetDate : null,
action,
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSlotId) : null,
newTeacherId: teacherChanged ? Number(teacherId) : null,
newClassroomId: classroomChanged ? Number(classroomId) : null,
newLessonFormat: formatChanged ? lessonFormat : null,
comment: comment.trim() || null
}
};
}
export function buildCancelOverridePayload(source, comment) {
return {
baseRuleSlotId: source.baseRuleSlotId,
lessonDate: source.lessonDate,
targetLessonDate: null,
action: 'CANCEL',
newTimeSlotId: null,
newTeacherId: null,
newClassroomId: null,
newLessonFormat: null,
comment: comment.trim() || null
};
}
// --- Реестр правок ---
export function overrideRegistryItem(override, dictionaries) {
const target = override.targetLessonDate
? ` → ${formatDate(override.targetLessonDate)}`
: '';
const result = override.action === 'CANCEL'
? 'Занятие отменено'
: [
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
overrideTeacherName(dictionaries, override.newTeacherId),
overrideClassroomName(dictionaries, override.newClassroomId),
override.newLessonFormat
].filter(Boolean).join(' · ');
return {
id: override.id,
actionLabel: OVERRIDE_ACTION_LABELS[override.action] || override.action,
actionClass: `action-${String(override.action).toLowerCase()}`,
timeText: `${formatDate(override.lessonDate)}${target}`,
subject: override.subjectName || 'Занятие',
description: [
override.lessonTypeName,
(override.groupNames || []).join(', '),
result || 'Параметры изменены'
].filter(Boolean).join(' · '),
comment: override.comment || ''
};
}
// --- Контракты API ---
export function overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate) {
return `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(baseRuleSlotId)}`
+ `&lessonDate=${encodeURIComponent(lessonDate)}`;
}
export async function fetchOverrideAvailability(baseRuleSlotId, lessonDate, client = apiClient) {
return client.get(overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate), {
errorMessage: 'Не удалось загрузить доступные даты занятия'
});
}
export function effectiveTimeSlotsEndpoint(date) {
return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`;
}
export async function fetchEffectiveTimeSlots(date, client = apiClient) {
return client.get(effectiveTimeSlotsEndpoint(date), {
errorMessage: 'Не удалось загрузить время занятий'
});
}
export async function saveScheduleOverride(payload, overrideId, client = apiClient) {
return overrideId
? client.put(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, payload, {
errorMessage: 'Не удалось сохранить разовое изменение'
})
: client.post('/api/edu-office/schedule/overrides', payload, {
errorMessage: 'Не удалось сохранить разовое изменение'
});
}
export async function deleteScheduleOverride(overrideId, client = apiClient) {
return client.delete(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, {
errorMessage: 'Не удалось удалить разовое изменение'
});
}